Skip to content

feat(spec)!: publish the $-prefix key ban the normalized filter enforces, and make the ratchet able to see it - #19335

Merged
os-justin merged 7 commits into
mainfrom
claude/issue-18670-propertynames-not-pattern-arm
Sep 22, 2026
Merged

os-justin merged 7 commits into
mainfrom
claude/issue-18670-propertynames-not-pattern-arm

Conversation

@os-bill

@os-bill os-bill commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

⛔ PARKED — 本 head 落不了地,且挡住它的不是本 PR。 卡 #18670 已转 pm:blocked,门禁卡是 #19240(认领读者 claimRetractions 只认同一 login 的 Release:,SKILL.md :496 的死认领回收写不进它)。本 PR 的落地前置 ① 与 ③ 成立(达档 ## Contract review 记录 5749728565 在 head 1dfe2f40bc 上;checks 全绿);② 不成立:check-clause2-carriers.mjs --pair 19335 = exit 4,唯一 ✗ 行是 C9(本卡线程上两条他席认领仍 LIVE)。完整读数、对照与本席自纠见 #18670 评论 5752999363。⛔ 保持 draft,⛔ 不挂 auto-merge。

Part of #18670 — item 2, the fifth arm the batch #193 ruling added to the closed projection list, plus that ruling's second acceptance item. This body carries no closing keyword for that number on purpose: 566 dropped refinement sites remain across 205 published schemas, and whether the card closes is the seat's call rather than this PR's.

Clause-②: yes

Carrier: the published artefact packages/spec/json-schema/data/NormalizedFilter.json. The published JSON Schema narrows toward what the runtime already refuses, and no document the runtime accepts becomes refused.

Director ruling 5749025303, batch #193 item 3, letter A, maintainer 「其他同意」 2026-09-20T09:44Z: 「A fifth arm joins the closed projection list: propertyNames: { not: { pattern } }, scoped to that one site and to the ^\$ ban, under the same one-ledger-row-at-a-time discipline as the four landed arms; the published keyword and the enforced predicate are built from a single source so they cannot name different things; an ablation proves the pin (the emitter removed ⇒ the rows return).」

Base f93beea0a6; head after merging origin/main (e3b3cdd2df) through scripts/pm/os-regen-merge.sh: 1dfe2f40bc.


1. The measurement that decided step 1 — and it came out YES

The ruling put one measurement before the arm: can those three NormalizedFilter.json nodes hold a ledger row at all? They read undecidable, and the thread's worry was that closing the rule would buy a narrower file with no testable row — the opposite trade from every arm landed so far.

⛔ It is not a grep question, and the card's own instruction says so: packages/spec/json-schema/** is 0 tracked files on origin/main (lit control, same instrument: packages/spec/src/data/ reads 167 tracked), because .gitignore:63 ignores it. Every reading below is against a tree generated by the repo's own tooling — pnpm --filter @objectstack/spec build, whose first step is gen:schema (OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts).

The answer: a row CAN be held, and the reason it was not is a defect in the detector. The generator publishes NormalizedFilter through its THIRD projection attempt — projectByPruningUnionBranches, which drops the z.date() union branches and publishes the rest. The detector's projectOrNull stopped at the two strict rungs. So it was asking what a projection nobody publishes says, and answering undecidable:

node plain output rung plain input rung branch-pruning rung differential under it
lazy.$and.element.options[0] throws throws ok, 16772 bytes identical ⇒ dropped
lazy.$or.element.options[0] throws throws ok, 16772 bytes identical ⇒ dropped
lazy.$not.options[0] throws throws ok, 16772 bytes identical ⇒ dropped

⇒ the ruling's first branch applies: the detector judges those three nodes. The undecidable row shape was its fallback 「if a row cannot be held」, and that antecedent is false, so ⛔ no unread ledger field was added for an empty population. What the hole got instead is §2.

2. Second acceptance item — the blind spot, measured to zero and then pinned there

projectOrNull now carries the generator's third rung and reports which rung answered, so a differential can never compare a pruned projection with an unpruned one (nothing observed reaches that guard; it is written down so the day it stops holding reads undecidable and is counted, rather than reading projected and vanishing).

Repo-wide effect, from the generator's own census line:

published schemas dropped sites projected undecidable
base f93beea0a6 204 560 357 9
+ the ladder rung 205 569 357 0
+ the arm (this PR) 205 566 360 0

⚠️ The ledger GREW before it shrank, and the growth is the whole point of the item. Seven sites became countable that no ratchet could see — data/FieldOperators and data/NormalizedFilter each gained their $between pair, and data/RangeOperator entered the ledger at all, a published schema that had been holding zero entries. Then the arm deleted three. Net: 204 entries / 560 sites → 205 / 566.

And a published site that still cannot be adjudicated now fails the build by name, printing the paths and the two legitimate remedies (teach the ladder a rung the generator has; or take the decision to give the ledger an undecidable row shape). ⛔ The hole cannot reopen in silence.

3. The arm, and the single source

banned-key-pattern — 「no document may carry a key matching this pattern」 — emitted as propertyNames with a not over a pattern. A $-prefix ban is an open key set, so the existing banned-keys arm cannot express it: a finite list that merely sampled the set would be wider than the rule, which the closed list forbids by construction.

Single source, asserted rather than argued. bannedKeyPattern compiles its regular expression from the declared pattern string, so the keyword the file publishes and the rule the runtime enforces are one string read twice. A test reads the emitted pattern off the published artefact and the declaration off the predicate and compares them — an emitter that re-spelled the rule, or a declaration edited without its predicate, fails there rather than drifting.

Exact, not approximate. A JSON object's properties are exactly its own enumerable string-keyed ones, and propertyNames judges exactly those names. JSON Schema specifies pattern as an ECMA-262 regular expression evaluated as a SEARCH — unanchored, "does a match occur anywhere" — which is RegExp.prototype.test and nothing else. So ^\$ and the hand-written key.startsWith('$') it replaces name one set, pinned over a key corpus. It is presence and never value: a matching key present with a null value is present to both.

Scoped mechanically, which is how the ③ objection is answered. The standing objection to a regex-shaped arm is that its over-reach cannot be read off the declaration the way a key list's can. The bound is a second closed list: BannedKeyPattern is a union of the pattern strings this package publishes, exactly one today, so a call site cannot invent a regex — there is no plain string type to pass, and widening it is the same reviewed decision that adding an arm is. The compiler refuses the second pattern; it does not arrive by a call site's choice.

⛔ No flags on the regular expression, and that is part of the equality rather than a style choice: a JSON Schema pattern has none to carry, and the global flag would make test stateful through lastIndex, so a key's verdict would depend on which keys were judged before it. Pinned both ways.

⛔ The predicate reads OWN enumerable keys and never the in operator — pinned with a name planted on the prototype, where the two readings actually come apart.

4. The card's own class, before and after — measured with a real validator

ajv 8 (draft 2020-12) compiled against the generated data/NormalizedFilter.json on each side:

document ajv BEFORE ajv AFTER
{} true true
{"$and":[{"amount":{"$eq":1}}]} true true
{"$and":[]} true true
{"$and":[{"$and":[]}]} true true
{"$or":[{}]} true true
{"$not":{}} true true
{"$not":{"amount":{"$eq":1}}} true true
{"$and":[{"$bogus":{"$eq":1}}]} true false
{"$or":[{"$bogus":{"$eq":1}}]} true false
{"$not":{"$bogus":{"$eq":1}}} true false

The three that move are refused by the runtime, which names the rule: 「a field condition's keys are field names, never $-prefixed operators」. ⇒ the validator stops answering PASS on metadata the platform refuses, and nothing the runtime accepts became refused — the empty combinators and the nested group members are the direction that would have broken had the ban landed on the union instead of on the field-condition branch, and they are pinned.

All three published nodes now carry the rule, conjoined and never substituted (a record states propertyNames: { type: 'string' } of its own, and replacing it would trade a key-TYPE rule for a key-NAME rule — a narrowing bought with a widening):

{
  "type": "object",
  "propertyNames": { "type": "string" },
  "additionalProperties": { "...": "the operator map" },
  "allOf": [ { "propertyNames": { "not": { "pattern": "^\\$" } } } ]
}

5. Blast radius — the whole published tree

The six source files were reverted to the base, the generator re-run, and the two trees compared byte for byte. Revert leg proven on disk: each path's blob hash equalled its base blob before anything ran. Restore leg proven by bytes: git diff HEAD printed 0 bytes, git status --porcelain printed nothing, and each path's blob hash equalled its HEAD blob.

reading value
files common to both trees 1535
byte-identical 1530
moved 5

The five, by name: data/NormalizedFilter.json (gains the ban at three nodes; gains the two $between annotation rows the ladder made visible), data/FieldOperators.json and data/RangeOperator.json (annotation only — they gain x-dropped-refinements rows, and x- keywords are ignored by every validator, so the set of documents they accept is unchanged), objectstack.json (the bundle; its 29 differing leaf paths sit under exactly those three definitions and nowhere else), and .build-input-hash-schema.

⭐ openapi.json measured separately and with the right instrument. gen:schema never writes it, so comparing it inside the sweep above would have read two copies of the same stale file and reported a false identical. gen:openapi was run on both trees: sha256 34b1dc9c2cf103144fc0a174d4bc901836fd1f89d1d1a71c0aa36e2bfbeeebaa on both sides — this arm reaches no schema that surface publishes.

6. Ablation — the pin can fail, and the rows do return

scripts/ablation-replace.mjs replaced the one line dispatching the arm, with the mutation verified against the disk: anchor 1 → 0, marker 0 → 1, blob 4c5881bf5d1f → 92da85bc6406.

⭐ Resolution stated, because a false green here points the wrong way: every consumer reaches this module by a relative specifier, which resolves to source and never through the package exports to dist. There is no built artefact between the mutation and the verdict, so no dist preflight applies.

leg result
refinement-projection.test.ts exit 1 — 12 failed / 66 passed, the single-source pin and the live seam among them
gen:schema exit 1 — naming all three rows returning by name: lazy.$and.element.options[0], lazy.$not.options[0], lazy.$or.element.options[0]
restore blob back to 4c5881bf5d1f == HEAD, git diff HEAD 0 bytes, anchor back to 1 and marker back to 0

The second leg is the ruling's own requirement: 「the emitter removed ⇒ the rows return」. They do — and they exist to return only because §2 made those nodes countable first. Regenerated afterwards, data/NormalizedFilter.json came back to sha256 80041a0b…, byte-identical to the pre-ablation artefact.

7. Verification — real exit codes, each captured before any pipe

check exit
pnpm --filter @objectstack/spec build 0
pnpm --filter @objectstack/spec typecheck 0
pnpm --filter @objectstack/spec test 0 — 500 test files / 14663 tests, all passed, dist built
pnpm --filter @objectstack/spec gen:schema 0 — ledger balanced
pnpm --filter @objectstack/spec gen:openapi 0 — byte-identical to base
pnpm --filter @objectstack/spec check:generated 0 — 16 of 16 generated artefacts up to date
pnpm lint 0 — the whole repository, eslint . --no-inline-config, not a narrowed subset
derived gate families, reconciled by scripts/pm/dispatch-gates.mjs --ran 86 derived / 82 exit 0 / 4 NOT MEASURED / 0 UNRUN

The four NOT MEASURED each exit 3 — PREREQUISITE NOT MET, a code that is explicitly neither pass nor failure — because each needs a whole-repo build closure that CI produces: check:doc-formula-expressions, check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt. ⛔ Declared, not skipped.

⭐ api-surface-declarations/ moved, and the movement is order-only — but it IS mine. check:api-surface (the name-level gate) stays green with no diff at all. The declaration-text artefact did move, and rather than assume, it was tested: with this branch's six source files reverted to the base and the package rebuilt, check:api-surface-declarations exits 0 — so the movement belongs here. Characterised by bytes: 10 changed lines, 9 of them a whole-line multiset identity (two enum members swapping places), and the tenth a union whose quoted tokens are the same set, the same count, and whose text is identical once the tokens are masked. ⇒ no declaration added, removed, or changed in meaning. Regenerated and committed as its own commit.

8. Merge hygiene

origin/main was merged in through scripts/pm/os-regen-merge.sh — ⛔ never rebased, ⛔ never force-pushed. That path was taken because git check-attr merge reads os-regen on packages/spec/api-surface-declarations/api.txt and system.txt, per file rather than by counting .gitattributes rows. After the merge the implementation body was re-asserted by name (bannedKeyPattern, OPERATOR_PREFIX_KEY_PATTERN, BannedKeyPattern, emitBannedKeyPattern, conjoinPropertyNames, undecidableEntries), the whole chain was regenerated, and check:generated reported 16 of 16 current with no regeneration diff.

Acceptance notes

  • ⚠️ A dispatch instruction that the repository contradicts, named rather than quietly resolved. The dispatch said to regenerate packages/spec/dropped-refinements.baseline.json 「with the repo's tooling; never hand-edit it」. There is no such tooling: the ledger has no gen: script by design, build-schemas.ts calls it 「a committed, hand-edited ledger」 in its own refusal text, and the module docblock argues the point at length — a generator would let a new gap be admitted by running a command instead of by a decision. The operative half of the ruling — 「⛔ do not serialise on it」 — was followed: this PR did not wait on spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147. Every ledger edit here is the corrected entry the gate itself printed, pasted verbatim, which is the closest thing to tooling the artefact has.
  • Noted, not filed — the sibling changeset in this same release now contradicts the tree. .changeset/18670-project-banned-keys.md records that the $-prefix sites 「stay unprojected … carry NO annotation and hold NO ledger row: published yet unratcheted」. True of its own tree, false of this one. ⛔ Not rewritten — a landed record of what that PR shipped — so this PR's changeset states the supersession instead, and the two read coherently as one CHANGELOG. Carrier: none needed; both entries publish together.
  • Noted, not filed — and this PR IS the carrier the previous one named. feat(spec)!: publish the banned-keys rule the tracing filter arm enforces #19137 named 「the next PR that edits packages/spec/scripts/build-schemas.ts」 as carrier for a stale mention of the retired api-surface-signatures.json. This PR does edit that file, so it inherits the hand-off, and it is being declined deliberately: the line is a documentation nit in a comment, not one of the three filing classes, and it is not this ruling's defect class. It survives at packages/spec/scripts/build-schemas.ts:874. Carrier: the next PR that edits that file for a reason of its own.
  • dropped-refinements.baseline.json is a shared hot file held by spec: pre-parse __proto__ guard on ObjectSchema.fields and AssignmentConfigSchema.assignments (#17852, #18847) #19147. Not serialised on, per the ruling; collisions resolve by regenerating through scripts/pm/os-regen-merge.sh, ⛔ never by hand-editing conflict markers.
  • The arm list's own roster pin and the new pattern-set pin are both asserted as exact equalities, so a sixth arm — or a second pattern — updates a reviewed line in a diff rather than widening the narrowing quietly.

Generated by Claude Code


Generated by Claude Code

…ator does, and a published site it cannot adjudicate now fails the build

The detector decided `dropped` vs `projected` on a two-rung ladder — output,
then input — while `build-schemas.ts` publishes on a three-rung one: a node
whose every io direction refuses over an unrepresentable member still reaches
its file when that member sits in a union position, because the emit loop drops
the branch and publishes the rest.

So nine PUBLISHED sites read `undecidable`: the comparison had no two sides, the
verdict the ledger does not count. Three of them are the `$`-prefix ban on a
normalized field condition, which published as a bare object and held zero
ledger rows — no repair of it could ever have deleted one.

`projectOrNull` now carries the generator's third rung, and reports WHICH rung
answered so a differential can never compare a pruned projection with an
unpruned one. Measured: 9 undecidable -> 0, and the nine become ordinary
`dropped` rows (+9 sites, +1 schema: data/RangeOperator was published holding
no ledger entry at all).

A published site that still cannot be adjudicated now fails the build by name,
so the ratchet's own blind spot cannot reopen in silence.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
Fifth arm of the closed projection list: `banned-key-pattern`, emitted as
`propertyNames: { not: { pattern } }` — the spelling JSON Schema has for a rule
about the SHAPE of a key name, where `banned-keys` has one about a finite list.

Scoped to one site and one pattern. `BannedKeyPattern` is a closed union of the
patterns this repository publishes, exactly one today, so a call site cannot
invent a regex: there is no `string` to pass, and widening it is the same
reviewed decision that adding an arm is. That is the bound on the objection a
regex-shaped arm has to answer — over-reach a reader cannot see in the
declaration is held down by how few declarations exist.

Single source: `bannedKeyPattern` compiles its `RegExp` FROM the declared
pattern string, so the keyword the file publishes and the rule the runtime
enforces are one string read twice and cannot come to mean different things.
Flagless, deliberately — a JSON Schema `pattern` has no flags to carry, and `g`
would make `test` stateful through `lastIndex`.

`data/NormalizedFilter.json`'s three field-condition record nodes now state the
ban. Measured with ajv 8 on the generated file: the specimen the runtime
refuses is refused at all three nodes, and every document the runtime accepts —
the empty combinators and group members included — is still accepted.

Ledger: 3 rows deleted, 0 added. Census 566 dropped / 205 schemas / 360
projected (3 banned-key-pattern) / 0 undecidable.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
… made its rows exist

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
The new import edge into `src/data/filter.zod.ts` moves the order TypeScript
emits some type-literal members in. Measured: reverting this branch's six
source files to the merge base makes `check:api-surface-declarations` green
again, so the movement belongs to this change and not to `main`.

Characterised rather than waved at: 10 changed lines, 9 of them a whole-line
multiset identity (two `z.ZodEnum` members swapping places) and the tenth a
union whose quoted tokens are the same set, the same count, and the same text
once the tokens are masked. ⇒ no declaration added, removed or changed in
meaning, and `check:api-surface` — the name-level gate — stays green with no
diff at all.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 20, 2026
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 70dccce03842a4e43abd91ca38ae6bafa1a28174 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 288b913aaa56f8130edd8ff4cc720187a6271dda — the merge of head 23ab364fe5432ccc5e6a0f8b04a349ed20b71a3d into base 70dccce03842a4e43abd91ca38ae6bafa1a28174, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 288b913aaa56f8130edd8ff4cc720187a6271dda && git checkout 288b913aaa56f8130edd8ff4cc720187a6271dda
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 70dccce03842a4e43abd91ca38ae6bafa1a28174 23ab364fe5432ccc5e6a0f8b04a349ed20b71a3d && git checkout -B drift-repro 70dccce03842a4e43abd91ca38ae6bafa1a28174 && git merge --no-ff 23ab364fe5432ccc5e6a0f8b04a349ed20b71a3d

node scripts/docs-audit/affected-docs.mjs --json 70dccce03842a4e43abd91ca38ae6bafa1a28174

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

os-bill commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1dfe2f40bce77270758d9b31b01dd8d46875a290

Independent contract review of PR #19335 against card #18670 and the maintainer ruling 5749025303 (batch #193 item 3, letter A). Reading time 2026-09-20T12:14Z. Every number below was re-taken in this session's own worktrees — head 1dfe2f40bc (merge-base with origin/main = e3b3cdd2df), control at the merge-base e3b3cdd2df, and a second control at origin/main 81e12e186f — never from the PR body, the dev report or any seat comment. packages/spec/json-schema/** is untracked (git ls-files packages/spec/json-schema = 0 files, .gitignore:63; lit control on the same instrument: git ls-files packages/spec/src/data = 167 files), so every published-tree reading here is against a tree generated by pnpm --filter @objectstack/spec build (head, exit 0) or pnpm --filter @objectstack/spec gen:schema && gen:openapi (both controls, exit 0).

① Derived judgments

Executed the ruling, and only that — YES.

  • Ordering (step 1 before the arm) — honoured in the history, not only claimed. Commit 40727ab0e7 (2026-09-20T10:28:19Z) touches only scripts/build-schemas.ts, scripts/lib/dropped-refinements.ts and the ledger, and adds the three nodes lazy.$and.element.options[0] / lazy.$or.element.options[0] / lazy.$not.options[0] to data/NormalizedFilter as ordinary dropped rows — that IS the step-1 answer (a row can be held). Commit 132e3e677e (2026-09-20T10:34:53Z) then lands the arm and deletes those three rows. Neither commit touches the other's surfaces. Nit, not a defect: the detector commit's own measured block still reads 204/560/357/9 (only the head's reads 205/566/360/0); the generator does not gate that block, and the head ledger is what lands.
  • The arm is the one prescribed. emitBannedKeyPattern writes propertyNames: { not: { pattern } } through the shared conjoinPropertyNames, which keeps the record's own propertyNames: { type: 'string' } and appends the ban under allOf — verified on the generated head file at all three nodes (type: object, propertyNames: {type: string}, allOf: [{propertyNames: {not: {pattern: "^\$"}}}], the pattern being caret, backslash, dollar).
  • Single source — verified on the code and by a compile-time probe. bannedKeyPattern(keyPattern) builds its RegExp from the very keyPattern it stores in the declaration; the emitter reads declared.keyPattern; declare() is module-private so no other constructor of a banned-key-pattern declaration exists (grep: the only non-test call site is src/data/filter.zod.ts:1925; lit control bannedKeys( hits src/system/tracing.zod.ts). A call site cannot introduce a pattern the predicate does not enforce because there is no path from a string to the emitter: tsc --strict on a probe file refuses bannedKeyPattern('^x') (TS2345), a hand-built declaration with '\$' (TS2322) and a plain string variable (TS2345), while the sanctioned bannedKeyPattern(OPERATOR_PREFIX_KEY_PATTERN) compiles. Head typecheck exit 0.
  • Scope mechanically closed. BannedKeyPattern is the literal type of one exported constant; the test suite pins that constant to ^\$ and the arm roster to exactly five names; the generator census at head prints 3 banned-key-pattern projected sites and the ledger diff is those three deletions. No doc, skill or script outside packages/spec restates the arm roster (repo grep for dependent-required / banned-keys / PROJECTABLE_REFINEMENT_PATTERNS outside spec source/tests: 0 files; lit control: the PR's changeset, 3 hits).
  • Second acceptance item — the 9 → 0 premise verified, not inherited. build-schemas.ts reaches the file through three rungs (projectPublishedJsonSchema output → input → projectByPruningUnionBranches, lines ~512–533); the detector's projectOrNull at the merge-base stopped at two. Re-taken census: merge-base e3b3cdd2df and origin/main 81e12e186f both print 560 dropped / 204 schemas / 357 projected / 9 had no JSON form; head prints 566 / 205 / 360 / 0 published site(s) could not be adjudicated. The three NormalizedFilter record nodes went undecidable → dropped (detector commit) → projected (arm commit), which is what makes the arm's ledger deletions real. The new rung-mismatch guard and the build-failing undecidable check are present and the test file carries a lit control (probe/NeverProjects still reads undecidable).
  • Ablation — re-run here, not adopted. Lit control first: the projection test file on the unmutated head passes 78/78 (exit 0). Then, through the repo's own scripts/ablation-replace.mjs, the one dispatch line emitBannedKeyPattern(jsonSchema, declared.keyPattern); was replaced in scripts/lib/refinement-projection.ts (anchor 1 → 0, blob 4c5881bf5d1f → 26f96de45399, verified on disk): gen:schema exits 1 naming exactly lazy.$and.element.options[0], lazy.$not.options[0], lazy.$or.element.options[0] as returning gaps, and the test file exits 1 with 12 failed / 66 passed (the single-source pin, the three-node live seam and the ledger-row pin among them). Restore proven: blob back to 4c5881bf5d1f == HEAD, git diff HEAD empty, porcelain clean; the head tree regenerated afterwards is byte-identical to the pre-ablation data/NormalizedFilter.json (see gates line). The rows return only because the detector's third rung made them countable — the ruling's own requirement, observed rather than inherited.
  • Pin discrimination on over-reach — a second ablation of my own. The ruling's ablation proves absence; it does not prove the pin refuses a WIDER pattern, which is the objection to a regex arm. So I also mutated the source string from ^\$ to \$ (unanchored, which would ban a$b and x$) and re-ran the test file: exit 1, 3 failed / 75 passed — the closed-pattern-set pin (disagreement on a$b), the declaration pin, and the three-node live-seam shape pin. Restored, blob 6c3158d271 == HEAD, porcelain clean. The pins are not vacuous in either direction.

② Semver level

Clause-②: yes is right, and (narrowing) is the right arm. Nothing was added to any accept set; the published data/NormalizedFilter.json (and its $defs copy in objectstack.json) now refuses documents it accepted, so the published artefact narrows. Changeset .changeset/18670-project-operator-key-pattern.md: @objectstack/spec: minor with a BREAKING banner (launch-window convention), Clause-②: yes (narrowing), ADR-0087 marker not-required (no-migration-prescription) — node scripts/check-adr-0087-registration.mjs exit 0, check-changeset-no-major.mjs exit 0, check-empty-changeset.mjs exit 0. The pair predicate check-clause2-carriers.mjs --pair 19335 reads the governing claim as 5749165780 naming this branch, Clause-②: yes on both card and PR.

Migration text: the changeset names the refused specimen ({"$and":[{"$bogus":{"$eq":1}}]}), the node, and the rule (a field condition's keys are field names such as amount / account.name, never $-prefixed operators). An author hitting the new validator refusal was already hitting the runtime refusal with the same sentence, and the fix is stated by the rule (put the operator under a field key). I would have liked one explicit "to fix:" line, but nothing an author can write is removed or re-spelled, so the FROM → TO obligation does not strictly apply; noted, not failed.

③ Boundary flags

  • Blast radius, re-taken on the merge-base control (the right control — origin/main carries 4 spec zod changes the head does not, which contaminate a main-vs-head diff with unrelated .describe() movement): 1535 files common, 1530 byte-identical, 5 moved, by name .build-input-hash-schema, data/FieldOperators.json, data/NormalizedFilter.json, data/RangeOperator.json, objectstack.json. With every x-* key stripped, the ONLY semantic change in the whole tree is the three propertyNames/not/pattern leaves under data/NormalizedFilter (file and bundle); FieldOperators and RangeOperator are annotation-only. openapi.json sha256 34b1dc9c2cf1… on merge-base, origin/main and head alike.
  • ajv leg, re-taken (ajv 8.20.0 from the repo's own store, draft 2020-12, strict: false) on a 24-document corpus, against the runtime NormalizedFilterSchema.safeParse on the same corpus (runtime verdicts identical at head and origin/main, 24/24): the PR's three specimens go true → false; two further $-prefixed probes of mine — {"$and":[{"amount":{"$eq":1},"$x":{"$eq":2}}]} and {"$and":[{"$eq":{"$eq":1}}]} — also go true → false and are runtime-refused, so the narrowing is exactly the ^\$ class and not wider. Every runtime-accepted document stays true on both sides, including the boundary shapes a wider pattern would have caught: a$b, x$, the empty key "", account.name, nested groups {"$not":{"$not":{}}} / {"$not":{"$and":[…]}} / {"$or":[{"$or":[…]}]}, $between, and every empty combinator. Zero runtime-accepted documents became refused.
  • Not this PR's, pre-existing and unmoved, named so nobody re-derives it: {"$and":[{"amount":{"$bogusop":1}}]} is runtime-accepted (non-strict FieldOperatorsSchema strips the unknown operator) and ajv-refused on merge-base, main and head alike (additionalProperties: false on the published operator map). That is the opposite direction from this card's class and is byte-identical across the diff.
  • api-surface-declarations movement: order-only (enum member swaps optional/required and one union re-ordering, same token sets). At head check:api-surface-declarations exit 0 and check:api-surface exit 0; at the merge-base with its own build build exit 0, check:api-surface-declarations exit 0 ("declaration text unchanged, 17 entry points, 5364 declarations"), check:api-surface exit 0 — so the committed artefact is consistent with its own source on both sides and the movement is attributable to this diff, as the PR says.
  • Gates re-run at head, real exit codes: build 0 · gen:schema (inside build) 0 with ledger balanced · check:generated 0 · check:docs 0 · check:api-surface 0 · check:api-surface-declarations 0 · typecheck 0 · test 0 (500 files / 14663 tests passed) · root pnpm lint NOT MEASURED — whole-repo eslint was still running in my worktree when the coordinator asked for the record; CI reports Lint & Repo Gates on this head, which I did not re-derive · changeset gates 0/0/0 (above). NOT MEASURED: scripts/pm/dispatch-gates.mjs --ran derived families and the four whole-repo-closure gates the PR lists — no repo-wide build was made in this worktree.
  • No governed surface in the file list (10 files: .changeset/, packages/spec/** only). Draft, needs:contract-review present. No label or body was touched by this review.

Independence pair —
(a) Measured first-hand: the untracked-tree fact; commit order and per-commit file sets; the compile-time closure probe; the ajv leg on three generated trees plus the runtime corpus; the whole-tree comparison and semantic diff at merge-base and at origin/main; the openapi hashes; the census lines on three trees; both ablations; every exit code listed above.
(b) Taken from someone else's reading and NOT verified: the PR's dispatch-gates.mjs --ran tally (86/82/4/0); CI's own green on the head (only observed as check-run status, not re-derived); the PR's claim that its merge went through os-regen-merge.sh.

Implemented-by: claude/issue-18670-propertynames-not-pattern-arm
Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3

VERDICT: PASS


Generated by Claude Code

…opertynames-not-pattern-arm

Conflicts resolved:

- packages/spec/api-surface-declarations/{api,system}.txt (modify/delete):
  took origin/main's deletion. #19024 reverted the declaration-text snapshot
  wholesale on main - the generator (build-api-surface-declarations.ts), the
  package.json scripts, the check-generated entry and the .gitattributes
  merge=os-regen row are all gone, and api-surface-signatures.json is back.
  This branch's only touch of those two files was a regeneration commit
  ("member order only"), so nothing hand-authored is lost by the deletion.

- packages/spec/dropped-refinements.baseline.json (content): the conflict was
  confined to the `measured` census block. The entries map text-merged, so the
  header counts describe neither side. Resolved to a committable state here;
  the regeneration commit that follows carries the corrected entry the gate
  itself prints.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
… ledger body

The merge stacked both sides' entries: main's `api/DatasetSelection` row plus this
branch's `data/RangeOperator` row, and twelve shared keys whose site lists differ.
The `measured` header was the one conflicted hunk and described neither side.

Corrected to the merged tree's own arithmetic, which `gen:schema` prints and
`scripts/dropped-refinements.test.ts` pins against the body:

  publishedSchemasWithDroppedRefinements  205 -> 206   (= entries in the body)
  droppedRefinementSites                  566 -> 571   (= sum of the body's sites)
  refinementSitesThatDidProject           360 -> 369   (generator census, this run)
  refinementSitesWithNoJsonFormToCompare    0 ->   0   (unchanged; the hole stays closed)

The arm this branch adds is live in the merged tree: the generator's by-pattern
breakdown reads `banned-key-pattern 3`.

No generator writes this file by design, so this is the hand edit the ledger's own
description prescribes, and nothing but the census block moved.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 23ab364fe5432ccc5e6a0f8b04a349ed20b71a3d

Fresh at-tier review of PR #19335 against card #18670 and ruling 5749025303 (batch #193 item 3, letter A), on the head the merge with origin/main produced. It replaces record 5749728565, which governs only the head it names (1dfe2f40bc) and is void for this one. Reading time 2026-09-22T14:30Z. Every number below was re-taken in this session's own worktrees — head 23ab364fe5 and a control at the merge-base fa29803417 (the merge commit's second parent and git merge-base origin/main HEAD) — never inherited from the PR body, the merge round or the prior record. packages/spec/json-schema/** is untracked (.gitignore; 0 tracked files), so every published-tree reading is against a tree generated here: pnpm --filter @objectstack/spec build on the head (exit 0) and gen:schema + gen:openapi on the control (exit 0 / 0).

① Derived judgments

The merge round's three scoping claims — all three HOLD, each checked rather than assumed.

  • Blob identity. At 1dfe2f40bc and 23ab364fe5 the seven authored files carry the same blob: .changeset/18670-project-operator-key-pattern.md 9be85cb0, scripts/build-schemas.ts c886e177, scripts/lib/dropped-refinements.ts ca0d2121, scripts/lib/refinement-projection.ts 4c5881bf, scripts/refinement-projection.test.ts 4b45a53d, src/data/filter.zod.ts 4dd3e783, src/shared/refinement-projection.ts 6c3158d2. No hand-authored logic or prose moved.
  • One hand-authored file moved, and how. Merge commit c9092b2173 (parents 1dfe2f40bc, fa29803417) differs from BOTH parents in exactly one path, packages/spec/dropped-refinements.baseline.json (blob aa8809da → 8cd77ae0 → 220e341b at head). The entries body text-merged from main's side (api/DatasetSelection, the manifest.hooks.element.object rows, the fields.out.valueType re-spellings) beside this branch's data/RangeOperator row; the reconcile commit 23ab364fe5 then moved only the four measured numbers 205→206, 566→571, 360→369, 0→0. That the merged BODY is right is proven by the generator, not by reading the diff: adding, removing or moving a site fails gen:schema by name, and on the merged tree it exits 0 with the ledger balanced and no refusal.
  • The two api-surface-declarations conflicts. 17 files under that directory at the old head; 0 at the merge commit, at the new head and on origin/main; api-surface-signatures.json present at head with main's blob (b2099d11). 2277d1fcd1 (revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #19024) deleted the generator, the package scripts, the check-generated entry, the .gitattributes row and the workflow step (31 files, 239,156 deletions) and restored the signature file; at head no script, gate or attribute names the retired artefact. The branch's only touch of those two files was its member-order regeneration commit, so taking main's deletion lost nothing hand-authored and was the only resolution consistent with the tree.

The census — the one hand-authored thing the merge changed — is the merged tree's own arithmetic. gen:schema on the head prints 571 refinement site(s) across 206 published schema(s), 369 refinement site(s) DID reach the file, 0 published site(s) could not be adjudicated, by pattern 224 non-blank-string · 129 required-one-of · 11 banned-keys · 3 banned-key-pattern · 2 dependent-required. The header at head reads 206 / 571 / 369 / 0 — equal to the printout on all four. Body arithmetic re-taken: 206 entries, 571 sites. dropped-refinements.test.ts ("names at least one site per entry, and its header totals match its body") passes on the head, and it pins only the first two numbers; the generator reads no measured key at all (0 hits in build-schemas.ts and dropped-refinements.ts), so 369 and 0 are held by nothing but a reading — taken here and equal. Control: the merge-base prints 565 / 205 / 366 / 9, which is exactly main's header. The old header (205 / 566 / 360 / 0) and main's (205 / 565 / 366 / 9) each described one side and neither the merge; the reconcile is the correct sum (206 = 205 + 1 entry, 571 = 565 + 6 sites, 369 = 366 + 3 banned-key-pattern, 0 undecidable because the detector's third rung lands with this branch). The defect looked for — a header describing neither side — is absent at this head.

The accept/reject delta on the published artefact — the whole clause-② question. Generated trees compared byte for byte, merge-base vs head: 1538 files common, 1533 byte-identical, 5 moved, 0 present on one side only. The five: .build-input-hash-schema, data/FieldOperators.json, data/NormalizedFilter.json, data/RangeOperator.json, objectstack.json. With every x-* key stripped, FieldOperators and RangeOperator are equal (annotation only, invisible to a validator) and the ENTIRE semantic movement of the tree is three added leaves in data/NormalizedFilter.json — properties.$and.items.anyOf[0].allOf[0].propertyNames.not.pattern, the same under $or, and properties.$not.anyOf[0].allOf[0].propertyNames.not.pattern, each ^\$ (caret, backslash, dollar) — plus the same three under $defs/data/NormalizedFilter in the bundle; 0 leaves removed, 0 changed. Each node keeps type: object, its own propertyNames: {type: string} and its operator-map additionalProperties; the group branch beside it stays a bare $ref. openapi.json sha256 254d8866… on both trees. Head data/NormalizedFilter.json sha256 80041a0b….

ajv 8.20.0 (draft 2020-12, strict: false, from the repo's own store) compiled against both generated files over a 24-document corpus, crossed with NormalizedFilterSchema.safeParse on both trees (runtime verdicts identical, 24 of 24): the 17 runtime-accepted documents read true → true — {}, the empty combinators, {"$or":[{}]}, {"$not":{}}, nested groups three deep, account.name, a$b, x$, the empty key, $between, a two-field condition; the six $-prefixed documents read true → false and every one is runtime-refused — $bogus under $and, $or and $not, $x beside amount, $eq as a field key, the bare $; and the pre-existing opposite-direction specimen {"$and":[{"amount":{"$bogusop":1}}]} (ajv-refused, runtime-accepted) reads false → false, unmoved. Zero runtime-accepted documents became refused; the narrowing is exactly the ^\$ class. The PR's claim holds in both directions.

Single source, and a pin that can fail. bannedKeyPattern compiles its RegExp from the very keyPattern it stores in the declaration; the emitter writes declared.keyPattern; declare() is module-private, so no second constructor of a banned-key-pattern declaration exists; the only non-test call site is src/data/filter.zod.ts. The pin (refinement-projection.test.ts, "the published keyword IS the declared string") reads allOf[0].propertyNames.not.pattern off a published node and compares it with projectableRefinementOf(rule).keyPattern; its evaluator compiles from the EMITTED string, never from the imported constant, and throws when a node carries no pattern, so it cannot pass vacuously. Discrimination shown twice on this head through scripts/ablation-replace.mjs: (a) the dispatch line emitBannedKeyPattern(jsonSchema, declared.keyPattern); replaced (anchor 1 → 0, blob 4c5881bf5d1f → 6b5c6f1adc72) — the test file 12 failed / 66 passed, and gen:schema exit 1 naming exactly lazy.$and.element.options[0], lazy.$not.options[0], lazy.$or.element.options[0] as sites returning to the ledger (the ruling's own "emitter removed ⇒ the rows return"); (b) the source pattern widened from ^\$ to unanchored \$ (blob 6c3158d27123 → 5698bc90d9de) — 3 failed / 75 passed: the closed-pattern-set pin (disagreement on a$b), the declaration pin, and the three-node shape pin. Both restores proven by the tool: blob equals HEAD, git diff HEAD empty, porcelain empty; the head tree regenerated afterwards returns data/NormalizedFilter.json to sha256 80041a0b… and the census to 571 / 206 / 369 / 0. Unmutated, the two pin files pass: 2 files, 105 tests.

Scope of the regex arm — a plain string cannot be passed. BannedKeyPattern is typeof OPERATOR_PREFIX_KEY_PATTERN, the literal type of one exported constant. A probe compiled with tsc --strict against the head source: bannedKeyPattern('^x') refused TS2345, bannedKeyPattern('\$') refused TS2345, a string-typed variable refused TS2345, while bannedKeyPattern(OPERATOR_PREFIX_KEY_PATTERN) compiles. The roster pin holds PROJECTABLE_REFINEMENT_PATTERNS at exactly five names and the pattern-set pin at exactly one. Neither bannedKeyPattern nor OPERATOR_PREFIX_KEY_PATTERN is re-exported from any package entry (src/index.ts, src/shared/index.ts: 0 hits; api-surface/, export-origins/, declaration-map/: 0 hits) — no public API was added, so the closed set is closed at the package boundary too.

Ruling conformance. Fifth arm propertyNames: { not: { pattern } }, one site, one pattern, single source, ablation — met. Second acceptance item — published-yet-undecidable nodes made countable — present and measured: the merge-base prints 9 undecidable, the head 0, and build-schemas.ts now fails the build by name on any undecidable published site, so the hole cannot reopen in silence. Item 2 census rows retired by name: the three NormalizedFilter field-condition nodes, which now read projected naming banned-key-pattern (pinned).

② Semver level

Clause-②: yes (narrowing) is right, on the card (takeover claim 5777326107), the PR body and the changeset alike. .changeset/18670-project-operator-key-pattern.md: @objectstack/spec: minor with the BREAKING banner (launch-window convention), Clause-②: yes (narrowing), ADR-0087 marker not-required (no-migration-prescription). Gates run on the head against the merge-base: check-adr-0087-registration exit 0 (one declared-breaking changeset, disposition read), check-changeset-no-major exit 0, check-empty-changeset exit 0 (one declaring changeset added, none from the base modified or deleted). json-schema is in the package's files, so the narrowing does ship. Nothing an author can write is removed, renamed or re-spelled and the accept set moves only where the runtime already refuses, so no FROM → TO mapping is owed; the changeset names the refused specimen, the node and the rule. One honesty note, not a defect: the changeset's census sentence (566 / 205 / 360; 204 → 205) describes this PR's delta against its own pre-merge base, and on the merged tree the ledger reads 571 / 206 / 369 — the sentence is about what this change did, not the ledger's resting state, and the ledger file itself is correct.

③ Boundary flags

  • CI on this head, and the one red the PM inherited. 38 check runs on 23ab364fe5: every completed run is success or skipped — Lint & Repo Gates, TypeScript Type Check and its four sub-checks, Build Core, Dogfood Regression Gate ×4, Temporal Conformance, Governed Surface Queue Guard, Test Core 1, 2, 3, 5, 6, both Check Changeset runs. Test Core (4/6) — red on the first run at this head (packages/plugins/plugin-dev/src/dev-plugin-tenancy-posture.test.ts, "an explicit legacy false does not veto the authoritative posture", a 5009 ms timeout shape) — was re-run on the same commit and completed success, observed in this review's reading window, so every check on this head is green; that package is not in the 8-file diff, and the same job is green on main at fa2980341, fb7b74691 and ce951648e. Path analysis, first-hand: packages/plugins/plugin-dev's only spec import is postureEnforcesWall from @objectstack/spec/security; the built dist/security/index.js is a self-contained bundle whose sole import is zod and which carries none of this PR's code (the chunks carrying banned-key-pattern are the root, data, shared, ai, ui and browser/* entries); every other package DevPlugin.init() reaches is vi.mocked to throw in that test; src/shared/refinement-projection.ts has zero imports, so no cycle was introduced; and the diff's only runtime-shaped change is one flagless RegExp compiled once at module load. There is no path from this diff to tenancy posture. The PM's not-this-PR's reading stands on its own evidence, and this review adds the mechanism.
  • PR body §5, §7, §8 are stale in specifics, not in substance. §8 names head 1dfe2f40bc; §7 and §8 say check:generated reported 16 of 16, and on the merged tree it reads All 15 generated artifacts are up to date (exit 0) because revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #19024 retired the declaration-text gate — the denominator moved, nothing regressed; §5's 1535 / 1530 is 1538 / 1533 here because main grew, with the same five moved files; §7's api-surface-declarations paragraph describes a commit whose files no longer exist at head. None is a contract defect.
  • Merge state at reading. Draft; mergeable: true, state blocked (draft plus checks). origin/main has moved past the merge base, but git diff --stat fa29803417 origin/main -- packages/spec is empty, so no second ledger conflict is pending.
  • No governed surface in the file list (8 files: .changeset/ and packages/spec/**). needs:contract-review present on the PR and the card. No label, body or draft flag was touched by this review.
  • Not this PR's, pre-existing and unmoved: the $bogusop unknown-operator gap runs in the opposite direction (ajv-refused, runtime-accepted) and is byte-identical across the diff.
  • Independence pair. (a) Measured first-hand: the untracked-tree fact; blob hashes across both heads; the merge commit's per-parent diff; revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes #19024's file set; the census on three trees; the header/body arithmetic; the whole-tree byte and x-*-stripped comparisons; the ajv and runtime legs; the openapi hashes; both ablations and their restores; the closure probe; the three changeset gates; check:generated; the two pin files; the dist chunk analysis. (b) Observed as check-run status only, not re-derived: Lint & Repo Gates, whole-repo typecheck, Test Core slices, Build Core; the full @objectstack/spec vitest run beyond the two pin files was not re-run here.

Implemented-by: claude/issue-18670-propertynames-not-pattern-arm
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

席位说明:上面那份复核记录的 Reviewed-by: 写的是本席的会话,这里把它说清楚,⛔ 不留给读者猜

domain:spec seat 5(session_01Sfe5YjBLwB9J3y8fvm2xq1,座位贴 #19357),2026-09-22T14:32Z。对复核记录 5778363104(head 23ab364fe5432ccc5e6a0f8b04a349ed20b71a3d,VERDICT: PASS)。

事实,逐条:

裁决由谁作出 本席派出的在档复核子代理,它按 CONTRACT_REVIEW_TIER 起,⛔ 不继承本席的任何结论,从 diff 与 origin/main 自行推导
裁决由谁采纳 本席
记录里 Reviewed-by: 为什么是本席的会话 id 该子代理是进程内子代理,没有独立的会话 id —— get_session 不带参数描述的是派它的会话。记录模板对该字段的定义是「the session that RENDERS or ADOPTS the verdict」,⇒ 写采纳方合规,但只看那一行会读成本席自签

⭐ 所以本条存在的理由:本席本轮实测未达档(CONTRACT_REVIEW_TIER 现读于刷新过的 origin/main,不等于本席服务档)。一份看起来像「未达档的席位自己签了条款②复核」的记录,正是本班早些时候已经栽过一次的形状 —— 当时两份记录被撤回重做。⇒ 这次不靠「字面合规」蒙混,把作出方与采纳方分开写在这里。

⚠️ ⛔ 本条不是追认、⛔ 不改那份记录的任何字段、⛔ 也不替它背书内容 —— 内容由它自己负责,本席只把身份说清楚。

本席对该记录亲自回读的四件(⛔ 不取子代理自述)

检查 读数
是否点名当前 head ✅ 记录含 23ab364fe5432ccc5e6a0f8b04a349ed20b71a3d,与 PR 现 head 一致
Served-tier: 写的是常量名 ✅ Served-tier: CONTRACT_REVIEW_TIER
⛔ 有无模型标识(AGENTS.md:446) ✅ 零命中
判决 ✅ VERDICT: PASS

顺带落一条 CI 读数,免得下轮重查

Test Core (4/6) 曾在本 head 上红过一次(plugin-dev 的 dev-plugin-tenancy-posture.test.ts,5009ms 超时形状)。判据:本 PR 的 8 个文件全在 packages/spec、⛔ 未碰 plugin-dev;该分片在 main 的 fa2980341 / fb7b74691 / ce951648e 上全绿。⇒ 按规矩只投一次确认,14:16 发出,14:29 绿。现读本 head:39 项检查、0 红、0 在跑,mergeable: clean。⛔ 本席没有投第二次,⛔ 没有跳过或禁用任何测试。


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 22, 2026 14:33
@os-justin
os-justin enabled auto-merge September 22, 2026 14:33
@os-justin
os-justin added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit 170fd83 Sep 22, 2026
59 of 61 checks passed
@os-justin
os-justin deleted the claude/issue-18670-propertynames-not-pattern-arm branch September 22, 2026 15:04
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…le, not only English (objectstack-ai#19345)

Fixes objectstack-ai#17508

Clause-②: no — enumerating a repeater's row children in a `*.form.ts`
and adding catalog leaves adds no key to a published payload and moves
no accept set; the same author input parses identically before and
after. Verified against the diff: no schema file is touched, no
`.describe()` or `.meta()` moves, and no row child declares a `type` (so
no new form input is offered for anything).

## What this lands

Studio renders a `type: 'repeater'` as a table. Its column names come
from the form's **declared** row children when the form declares any,
and from the served JSON Schema `items.properties[k].title` when it
declares none — and `os i18n extract` walks a form field's declared
`fields`, so only a **declared** child ever gets a
`metadataForms.TYPE.fields['PATH.PROP']` key. objectstack-ai#17232 (PR objectstack-ai#17500)
authored the English `.meta({ title })` on thirteen item schemas and
objectstack-ai#17505 / objectstack-ai#17506 on four more, but none of them had a catalog channel:
every one of those column heads reached a Chinese, Japanese or Spanish
author in English.

Both halves land together, as the ruling on this card requires:

1. **The form-child enumeration** — 112 row properties across fifteen
repeaters, each `label` equal to the item schema's own `.meta({ title
})` so the extractor's English source and the schema title stay one
string. No `type` on a row child: the row widgets stay schema-derived.
2. **All four catalogs** — `en` (generated from those labels) plus
hand-authored `zh-CN` / `ja-JP` / `es-ES` for every leaf.
3. **The pin**,
`packages/platform-objects/src/apps/translations/repeater-row-properties.test.ts`,
in the shape `dashboard-header-children.test.ts` established for objectstack-ai#17227,
**including its last test**: a translated leaf is never a copy of its
`en` source.

## The re-derived population

The card's figures are a 2026-09-10 reading at `76ddab772f`. Re-derived
on this branch's base through the platform's own predicate
(`z.toJSONSchema(getMetadataTypeSchema(type), { unrepresentable: 'any',
io: 'input' })`, the same instrument
`packages/spec/src/kernel/repeater-item-titles.test.ts` uses), at
`origin/main` = `e3b3cdd2df3`, 2026-09-20T11:12Z:

| | card, 2026-09-10 | measured, 2026-09-20 |
|---|---|---|
| catalog leaves for full localisation | 604 | **604** (151 row
properties x 4) |
| leaves belonging to titled properties | 348 | **504** (126 x 4) |
| row properties titled | 87 | **126** |
| carriers fully titled | 14 | **18** |

The total is unchanged; the titled share moved, exactly as the three
sibling cards landed since would predict — objectstack-ai#17505 paid
`dashboard:widgets` (17) and `dashboard:globalFilters` (10), objectstack-ai#17506 paid
`field:options` and `object:fields.options` (6 each, one
`SelectOptionSchema`). 87 + 27 + 12 = 126, and 14 + 4 = 18.

Of the 504, **12 leaves already existed** (`dashboard:header.actions`
from objectstack-ai#17227, and four each on `object:fields.options` and
`page:variables`), so **112 keys per locale are new** — 448 new leaves,
336 of them hand-authored translations.

Controls on the census, as required: **lit** —
`dashboard:header.actions` resolves with exactly
`['label','actionUrl','actionType','icon']`, all titled; **dark** —
`agent` / `tool` / `hook` / `position` declare no repeater at all and
contribute nothing, and a fabricated carrier id resolves to nothing.
Both are asserted in the pin, not only measured by hand.

## Translation quality, and how it was checked per locale

A glossary was built mechanically from the pre-change catalogs: every
`metadataForms.*` and `objects.*` leaf whose translated value differs
from its `en` source, indexed by the English string — 1342 distinct
English strings with at least one translation. Every new label was then
looked up in it, and the catalog's **dominant** existing rendering
reused where one existed:

- `Label` → 显示名称 / 表示名 / Etiqueta (18 / 19 / 20 existing uses, and the
exact values its `object.fields.options.label` twin already carries)
- `Name` → 名称 / 名前 / Nombre (30 uses each) · `Type` → 类型 / 型 / Tipo (8)
· `Description` → 描述 / 説明 / Descripción (18)
- `Filter` → 筛选 / フィルター / Filtro · `Scope` → 范围 / スコープ / Ámbito ·
`Options` → 选项 / 選択肢 / Opciones
- `Timeout (ms)` → the existing 超时(毫秒) / タイムアウト(ms) / Tiempo de espera
(ms) · `Input Schema` follows the existing `Output Schema` → 输入 Schema /
入力スキーマ / Esquema de entrada
- `field.options.*` mirrors its `object.fields.options.*` twin verbatim,
`Color de opción` included — the same `SelectOptionSchema`, so the same
words
- domain nouns come from the carriers' own already-translated
`helpText`: widget 组件 / ウィジェット / widget, dimension 维度 / ディメンション /
dimensión, measure 度量 / メジャー / medida, node 节点 / ノード / nodo, edge 连线 /
エッジ / conexión, region 区域 / リージョン / región

Two deliberate departures, both stated rather than hidden:

- **A bare `ID` is qualified by its row**, following the catalog's own
`Reference ID` → 引用 ID / 参照 ID / ID de referencia: 节点 ID / ノード ID / ID
de nodo. An unqualified `ID` would be byte-identical to its `en` source
in all three locales, which the pin's last test refuses — and rightly,
since a column head reading `ID` in a Chinese panel is the untranslated
state this card exists to end.
- **`Dataset` is translated** (数据集 / データセット / Conjunto de datos)
although the catalogs carry it untranslated elsewhere. That existing
`Dataset` is an `en`-echo — untranslated debt, not a chosen loanword —
so this introduces a term for a concept the catalogs do not yet name
rather than a second term for one they do.

## The one existing string that moves

`page.variables`' children were enumerated (by objectstack-ai#3786's round) **without
labels**. The extractor therefore emitted `humanizeFieldPath(path)` as
the English source, and `resolveMetadataFormSchemaTitles` wrote that
text back over the item schema's authored title: the panel's column head
read **`Source`** in every locale while `PageVariableSchema.source`
declares `.meta({ title: 'Written By' })`. Two English names for one
column, the unauthored one winning.

The form now declares the label, so `en` becomes `Written By` and the
three translations are re-authored with it (写入组件 / 書き込み元 / Escrito por).
That is the **only** pre-existing leaf this PR changes; everything else
is additive. The pin's new every-row-child-declares-a-label test is what
makes the class impossible to re-enter.

## Fenced out, and seen

- `view.columns` / `view.sort` / `view.tabs` — **untouched**. The census
surfaces all three (14 + 2 + 9 = 25 untitled row properties), and all
three enumerate no children, so they contribute nothing to this diff.
Their titling is objectstack-ai#17507's, which is open and unclaimed. The pin asserts
`view` is outside its population, so objectstack-ai#17507 will have to add the leaves
with the titles — by design.
- `retiredKey()` tombstones — **not titled**. The census excludes any
row property whose description opens `[REMOVED] `, and
`flow.nodes[].outputSchema` is the pinned dark control for that
exclusion.
- `object.fields.options` keeps its curated four-key subset (`default`
and the per-option `visibleWhen` CEL predicate are **not** added as
inputs) — that subset is a declared entry in
`metadata-form-zod-reconciliation.test.ts`'s ledger, and adding them
would widen the offered authoring surface against a recorded decision.

## Verification

Base `origin/main` `81e12e186f3` (merged in), head `1cf7f2ae8ca`. Exit
codes captured before any pipe throughout.

- **Gate families** — `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived **83** commands on this tree;
all 83 run with exit codes recorded and reconciled: `83 derived, 83 run,
0 NOT-MEASURED, 0 UNRUN` — "a DERIVED zero — all 83 recorded an exit
code and none of them is 3". Two first returned `PREREQUISITE NOT MET`
(exit **3**, not a finding): `check:i18n` and
`check:dual-build-cjs-loads` both needed built output. Both re-run green
after a full build — `check-i18n-bundles: OK (9 package(s) — all bundles
in sync, no undeclared authoring keys)`.
- **Tests** — `@objectstack/spec`: 501 files / 14655 tests passed,
`typecheck` clean. `@objectstack/platform-objects`: 41 files / 583 tests
passed, `typecheck` clean.
- **Repo-wide lint** — not narrowed: `eslint . --no-inline-config
--format json` over eslint's own declared population of **6926** files
at `1cf7f2ae8ca`, **0 errors, 0 warnings**, exit 0.
- **Ablation — the pin can fail, twice, on both of its load-bearing
halves.** Both legs rebuilt and verified against the artifact the suite
actually resolves (`@objectstack/spec` is unaliased in
`packages/platform-objects/vitest.config.ts`, so the forms come from
`dist/`).
- *Catalog leg* (no build needed — the catalogs are source to this
suite): set `zh-CN` `report.blocks.chart` back to its `en` source.
Result: `1 failed | 7 passed`, on `translated locales carry their own
text for every leaf, not a copy of the source`. Restored; `git status
--porcelain` empty.
- *Form leg*: dropped `label: 'Operator'` from `skill.form.ts`'s
`triggerConditions` child. Marker `label: "Operator"` in
`packages/spec/dist`: **6** files at baseline → **0** after the mutate
build → **6** after the restore build. Mutated run `2 failed | 6
passed`, naming `skill:triggerConditions.operator` on both `every
enumerated row child declares a label` and `en: each leaf IS the form's
declared label`. Restored run `8 passed`, `ablation-dist-preflight`
green on presence **and** on a clean tree, source blob back to its
`HEAD` hash byte-for-byte.
- ⚠️ **The first form-leg attempt was a void reading and is reported as
such.** Its marker was spelled `label: 'Operator'` (single quotes, as
the source has it) while `tsup` emits `label: "Operator"` into `dist` —
so the `--absent` pre-flight passed **vacuously**, on a marker that had
never been in `dist` at all. The run above is the re-take with the
corrected anchor; the numbers quoted are that run's.
- **Serial constraints re-taken first-hand** at 2026-09-20T12:17Z, not
inherited: the changed-file page of all **35** open PRs, **281** file
rows. **No open PR holds any `*.form.ts`, and none holds the translation
catalogs.** Firing control: the same map resolves
`packages/spec/src/data/filter.zod.ts` to objectstack-ai#19335 and
`packages/spec/src/shared/polarity-axes.ts` to objectstack-ai#19318, so it does see
`packages/spec/src` holders. Dark control:
`packages/spec/src/zzz-no-such.zod.ts` resolves to nothing.

## Acceptance notes

Seen while working here, left alone — none is in this card's scope and
none is filed:

- `scripts/ablation-dist-preflight.mjs`'s `--absent` mode conflates two
independent questions in one exit code. Its own header says `--absent`
"is two things at once: the mode for a DELETE ablation, and the restore
leg of a PLANT one", but its tree limb hard-codes the restore-leg
reading — it prints `restore leg: ... 1 path still differs from HEAD`
and exits **1** whenever the working tree is dirty. A DELETE ablation's
*mutate* leg is necessarily dirty (the mutation is the dirt), so that
shape can never pass its own pre-flight, and the failure text instructs
the author to restore the very mutation being measured. Reproduced here:
a driver that trusted the exit code aborted the ablation at exit 93 with
the dist assertion already printed green above it. This is a
reproducible defect in an instrument with a named repro, so it is
proposed for a card in the report rather than fixed here.
- `packages/spec/src/kernel/repeater-item-titles.test.ts`'s header
states the bundle overlay "only ever REPLACES a `title` that is already
there". `setSchemaTitleAtPath` in
`packages/spec/src/system/i18n-resolver.ts` returns `{ ...node, title }`
unconditionally, so it also *creates* one. The claim is inert for that
file's verdicts (an untitled property with no catalog leaf is untouched
either way), so this is a prose inaccuracy, not a defect.
- `object.form.ts`'s `fields.options` comment reads "the offered inputs
are exactly `SelectOptionSchema`'s authorable keys, minus
`visibleWhen`". `default` is also not offered, so the sentence
under-counts by one. The behaviour is correct and ledgered as a `subset`
in `metadata-form-zod-reconciliation.test.ts`; only the comment's
arithmetic is stale.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…nfig (the catchall site) (objectstack-ai#19419)

Fixes objectstack-ai#19151

Clause-②: yes (narrowing)

⚠️ **This diverges from the claim comment, deliberately and on the
record.** Claim 5751323556 declares `Clause-②: no`; the criterion as I
read it says `yes (narrowing)`. What lands here is a **refusal newly
added to a published parse surface** — the same act, on the same family,
that the sibling PR objectstack-ai#19147 declared `Clause-②: yes (narrowing)` in
`.changeset/17852-record-proto-key-preparse-guard.md`. Grading a sibling
site differently from its family is how a family stops being one, and of
the two possible errors, declaring `no` on a real narrowing is the one
that lets a contract narrowing land without contract review. The seat
owns the correction if it reads the criterion the other way; I write
this line once, here, and nowhere else.

---

## STEP ONE — the vendor line, re-read first-hand. It holds.

The card's premise arrived half second-hand, so this was the gate before
any edit.

**Which zod, and how it was resolved.** `packages/spec/package.json`
declares `"zod": "^4.4.3"`. Resolved from the package's own entry rather
than from the manifest text:

```
node -e "const {createRequire}=require('module');
         const r=createRequire('.../packages/spec/src/index.ts');
         const p=r.resolve('zod/package.json');
         console.log(p, require(p).version);"
=> /home/user/objectstack-issue-19151/node_modules/.pnpm/zod@4.4.3/node_modules/zod/package.json  4.4.3
```

`pnpm --filter @objectstack/spec why zod` reports **`Found 1 version of
zod`** — 4.4.3 — so the resolution is not one of two. (The lockfile does
carry a second, 4.6.1, reached only through the better-auth family;
`packages/spec` never sees it.)

**The line, at the cited coordinates.**
`node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/schemas.js`,
`handleCatchall` opens at 759 and the skip is at **767-769**, exactly as
reported:

```js
function handleCatchall(proms, input, payload, ctx, def, inst) {   // 759
  ...
  for (const key in input) {
    // skip __proto__ so it can't replace the result prototype via the      // 767
    // assignment setter on the plain {} we build into                      // 768
    if (key === "__proto__")                                                // 769
      continue;                                                             // 770
    if (keySet.has(key)) continue;
    ...
    const r = _catchall.run({ value: input[key], issues: [] }, ctx);
```

`grep -n '__proto__' v4/core/schemas.js` returns exactly two sites in
the file: **767-769** here, and **1496** in `$ZodRecord`'s open-key
branch — the one objectstack-ai#17852 measured and PR objectstack-ai#19147 guarded. One function
apart, same shape, and the `continue` sits above the schema that would
judge the key in both.

⇒ **`premise_still_valid: true`.** The card's quotation was not accepted
as evidence; it was reproduced.

## The defect, reproduced end to end on this tree

At `origin/main` `0870fb5418`, through the real exported schema:

```
input (JSON.parse) own enumerable keys : [ 'total', '__proto__', 'other' ]
AssignmentConfigSchema.safeParse        => success: true
parsed own keys                         : [ 'total', 'other' ]
```

with two lit controls in the same run: the identical config **without**
`__proto__` round-trips both keys (so the instrument can see keys at
all), and the same `__proto__` **inside** `assignments` is already
refused loudly by objectstack-ai#19147's record guard (so the instrument can see a
refusal). `JSON.parse` is what makes `__proto__` an own enumerable key;
an object literal's `{ __proto__: … }` sets the prototype and never
reaches either loop.

This lands on data an author wrote on purpose: the schema's own docblock
says its top-level keys may be flow variables, and the descriptor
declares `additionalProperties: true`.

## The fix

`refuseCatchallProtoKey` in
`packages/spec/src/shared/record-proto-key-guard.ts` — a **sibling** of
`refuseRecordProtoKey`, both now calling one private
`refuseProtoOwnKey`. Identical mechanism, identical refused name,
identical issue shape (`custom`, `path: ['__proto__']`).
`refuseRecordProtoKey`'s message bytes and behaviour are unchanged.

Why a second wrapper rather than a second call site of the first: the
refusal **names the parser that would otherwise drop the key**, and here
that is `.catchall()`, not `z.record()`. An author told their top-level
flow variable was dropped by "z.record()" would go looking at the
`assignments` map — a different slot, one level down, with a different
guard. A pin asserts the two messages name their own parser and not the
other's.

## Why a pre-parse guard — the two alternatives, eliminated by
measurement

1. **A key/catchall schema cannot see it.** The `continue` at 769 is
above `_catchall.run`, so no catchall — not even `z.never()`, whose
`unrecognized_keys` list is built inside the loop the `continue` already
left — ever receives the key. Same structural unreachability the record
guard's docblock records.
2. **Declaring `__proto__` in the object's own shape refuses every
config.** Measured: zod reads a declared key as `input["__proto__"]` and
tests presence as `"__proto__" in input`; on an ordinary object both
answer through the **inherited accessor**, so the value is
`Object.prototype` and the key is always "present". A plain config with
no `__proto__` authored came back `success: false` with an
`invalid_type` at `['__proto__']`. (It is also unwritable as an object
literal at all — `{ __proto__: schema }` sets the shape object's
prototype rather than adding a key, measured: the shape had one key,
`assignments`.)

That leaves the raw input, ahead of the parse.

## Why `__proto__` only — re-derived, not copied

The record guard refuses `__proto__` alone on the ground that
`constructor` and `prototype` reach the key schema unskipped. That
ground had to be re-established at this position, because it is a
different loop. Measured at the catchall, top level:

| authored top-level key | parse | key in the output |
|---|---|---|
| `constructor` | success | kept |
| `prototype` | success | kept |
| `toString` | success | kept |
| `__proto__` | success | **dropped** |

⇒ the reasoning transfers exactly, and for the same reason it was true
below: only `__proto__` is structurally unrepresentable. Everything else
round-trips, so refusing it here would be a narrowing no ruling ordered.
The `assignments` slot keeps its own guard; the two are different
parsers at different depths and neither covers the other.

## The pins, and their ablation

The sharpest pin asserts **behaviour**, through one `classify()` helper
that discriminates the three outcomes an authored key can meet —
`refused` / `silently-dropped` / `silently-kept`. A bare `success ===
false` would pass for a schema that refused every config; a bare key
check would pass for one that kept the key and reported success. Both
the defect and its over-correction are named, not assumed.

Beside them: an unguarded-object CONTROL that must stay
`silently-dropped` on this exact zod; a preservation row per
reserved-looking name; the previously-accepted shapes (empty config,
bare legacy config, the CEL envelope and its malformed counterpart); the
`assignments` guard and the array-form prescription still firing at
their own paths; and an invariance pin on the JSON projection.

**Ablation** (`scripts/ablation-replace.mjs`, anchor declared and hit
exactly once, mutation verified against the disk):

```
anchor  x1 -> x0 ; blob 50724ef -> 21c448025a16   (mutation landed)
result  Tests  6 failed | 88 passed (94)
restore blob after restore 50724ef == blob at HEAD 50724ef, `git diff HEAD` empty
```

Direction observed: **red**, as expected. The six that turn red are
exactly the six `objectstack-ai#19151` assertions. **The `objectstack-ai#17852` / `objectstack-ai#18847`
record-guard pins stay green under the same mutation** — which is the
pin that the two guards are independent, and that these six are not
riding on the other one's work. The fix was committed before the
ablation, so the restore leg points at a commit that really exists; the
subject resolves through the package's own `src` (a same-package
relative import), so no `dist` leg is involved and none is claimed.

## What else moved, and why

`packages/spec/dropped-refinements.baseline.json` — the guard wraps the
object in a `z.preprocess` pipe, so the `AssignmentValue` refinement the
JSON projection already dropped sits one segment deeper:
`assignments.out.valueType` becomes `out.assignments.out.valueType`.
**Same single site, same gap, no new one.** The build gate caught it and
printed the corrected entry verbatim; this is that entry.

Nothing else regenerated: `pnpm --filter @objectstack/spec
check:generated` reports **all 15 generated artifacts up to date**, and
the JSON projection is byte-identical to the pre-change baseline — same
`type`, same single `properties.assignments`, same `xExpression:
'value'` on the map value, same `additionalProperties`. The expression
ledger still derives `assignments.*` through
`getSchemalessNodeConfigJsonSchemas()`, because every spec walker
resolves a preprocess pipe to its OUT side (`pipeAuthorableSide`). All
four are pinned, not merely observed.

## Verification

Every exit code captured before any pipe.

| what | verdict |
|---|---|
| `pnpm --filter @objectstack/spec build && … check:generated` | exit 0
— all 15 artifacts current |
| `pnpm --filter @objectstack/spec test` | exit 0 — **504 files / 14754
tests** |
| `pnpm --filter @objectstack/spec typecheck` | exit 0 (test layer
included) |
| service-automation reconciliation suites (8 files: form↔Zod ledger,
expression ledger, config parse/schemas/unknown-keys, assignment
envelope ×2, logic nodes) | exit 0 — 117 tests |
| `dispatch-gates --commands` then `--ran` | **81 derived, 81 run, 0
UNRUN** |
| `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 |

Three of the 81 answered **exit 3 — PREREQUISITE NOT MET, which is not a
red and not a pass**: `check-plugin-teardown-shape --self-test` (its
positive control is pinned to a commit outside this shallow clone),
`check:dual-build-cjs-loads` and `check:type-check-debt` (both read a
whole-repo `dist/` this box did not build within the foreground cap). CI
builds and runs all three.

Two families were derived from a base four commits behind `origin/main`
and are named rather than assumed: `check:merged-result` and
`check:issue-citations` were wired into `lint.yml` after this branch's
base. Both were run anyway — green, after the citation-spelling
correction described below.

Measured for the changeset's disposition: **zero** authored use of
`__proto__` as a top-level key on an `assignment` node config, across
this repo, `examples/` and the `objectui` sibling — against a **lit
control of 100 authored `assignment` node declarations in 26 files here
and 11 files there**. The census is a working-tree reading at
`1418799698`, not a history question; this clone is shallow (boundary
`ae8edd2c4f71d6f6fea5261e8284997f5546392f`) and no count here depends on
history.

## Acceptance notes — noted, not filed

1. **`scripts/check-issue-citations.mjs` cannot resolve a same-repo
citation written `objectstack#N`.** `buildBoard` builds its probe set as
`rows.filter((r) => !r.qualifier)`, so every **qualified** citation is
excluded from the probe — while `classifyCitation` treats
`objectstack#N` as naming this repo and resolves it against that same
board. The number is therefore never on the board and always reports
`allocated-but-absent`. Two-leg measurement on this diff, same six
citations, same run mode: with `objectstack#17852` → `board: probed (1
citations)`, `2 allocated-but-absent`, exit 2; with `objectstack-ai#17852` → `board:
probed (2 citations)`, `6 resolves`, exit 0. Independent control: `GET
/repos/objectstack-ai/issues/17852` answers **HTTP 200**
(state `closed`), so the number resolves and the gate's own transport
would have found it had it asked. This diff's added citations use the
bare spelling, which is this repo's documented form for its own issues
and what the gate's failure text itself prescribes. The gate is not
otherwise touched here.
2. **`treeifyError` / `error.format()` throw on any issue path
containing `__proto__`.** Reproduced first-hand against objectstack-ai#19147's landed
record guard on zod 4.4.3: both throw `TypeError: Cannot read properties
of undefined (reading 'push')` on the `['assignments','__proto__']`
path, while the same call on an ordinary refusal path succeeds (lit
control). This guard uses the same `path: ['__proto__']` shape as its
landed sibling, deliberately — it adds no new exposure class, and
changing the path shape for one of the two would create two dialects and
pre-empt a decision that belongs to whoever takes that question.
objectstack-ai#19151's body already records this connection; it is not this change's
subject and not its acceptance condition.
3. **Two open PRs also hold
`packages/spec/dropped-refinements.baseline.json`** — objectstack-ai#19373 and objectstack-ai#19335.
That file is deliberately **not** `merge=os-regen` (recomputing a
shrink-only ratchet can widen it), so whichever lands second reads the
conflict by hand. No open PR holds either source file this change edits;
lit control on the same scan, 18:25:35Z: 11 open PRs touch
`packages/spec/` at all and 1 touches `packages/spec/src/automation/`.

Not filed here: the PM files what is worth filing, per ruling A-narrow's
own instruction that a further site is its own card **when measured**.

## Scope

One site. ⛔ No sweep over the 398 records, ⛔ no re-opening of objectstack-ai#17852's
ruling, and objectstack-ai#19147's `assignments` guard is untouched — it is correct
and it is not this change's. objectstack-ai#18670, the JSON-Schema projection gap, is
not addressed here and stays open.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…estTimeoutMs at the one platform fetch site (objectstack-ai#19388)

Fixes objectstack-ai#18975

Clause-②: yes

Ruling of record: comment `5729479418` — director seat summon objectstack-ai#24, batch
objectstack-ai#159 item 5, maintainer 「同意」 2026-09-18T11:42Z, letter **实现**. Not
re-adjudicated here. **The spec declarations do not move**: the
connector schema keeps every key, every bound and every default it had.

## STEP ZERO — where the platform-owned connector fetch actually lives

Measured before anything was written, on `origin/main` = `be7382d77e`
(2026-09-20T14:05Z), re-confirmed after the merge to `ada70122`.

**The ruling's wrapper already exists.**
`packages/spec/src/shared/resilient-fetch.ts` — exported as
`resilientFetch` from `@objectstack/spec/shared` — is the platform's
outbound-HTTP call: it already gave every attempt a 30s timeout and a
bounded exponential backoff with jitter and `Retry-After` handling. So
"land the wrapper there once, no gateway, no new subsystem" was
satisfiable without building anything new.

What the connectors do with it, measured per package:

| package | the one call its handler makes | before this PR |
|---|---|---|
| `connector-rest` | `rest-connector.ts` `request()` |
`resilientFetch(...)` |
| `connector-slack` | `slack-connector.ts` `callSlack()` |
`resilientFetch(...)` |
| `connector-openapi` | `openapi-connector.ts` `request()` | a naked
`fetch` — unbounded, never retried |
| `connector-mcp` | handlers to `client.callTool` | no `fetch` at all;
the MCP SDK owns the transport, with a hardcoded 30s `timeout` |

So the fetch site is **one shared wrapper plus one bypass to fold in**,
not several independent paths and not something that needed
restructuring. The gap was never "there is no wrapper" — it was that the
wrapper could not express the declared policy, and that no authored
value could reach it: `ConnectorProviderContext` carried none of the
three keys.

Holder check at claim time: across all 30 open PRs, zero touch any file
matching `connector`, `resilient-fetch` or `service-automation` (lit
control on the same scan: `packages/spec` hits 9, 22 and 9 files on PRs
objectstack-ai#19364 / objectstack-ai#19363 / objectstack-ai#19335).

## What landed

**One wrapper, extended by exactly what was missing.**
`ResilientFetchOptions` gains `strategy`, `backoffMultiplier`,
`maxDelayMs`, `jitter` and `retryOnNetworkError`. **Each defaults to the
behaviour the wrapper already had**, so a caller that passes none is
byte-identical to before.

**One mapping.** `connectorFetchOptions()`
(`packages/spec/src/integration/connector-fetch-policy.ts`) is the
single place a connector's declared policy becomes wrapper options — one
execution site, not one per connector package.

**One contract widening.** `ConnectorProviderContext` gains
`retryConfig`, `connectionTimeoutMs` and `requestTimeoutMs`, read-only
and resolved: the materializer parses `retryConfig` through
`RetryConfigSchema`, so a factory reads real values instead of
re-deriving the schema's defaults. The policy also joins the instance
signature, so editing it re-materializes the connector instead of
leaving the old policy serving until restart.

**The built-in HTTP providers honour it by construction.** `rest` and
`openapi` pass the context's policy into their connector builders.

✅ **RESOLVED at 2026-09-20T17:02:28Z — the work is on the branch; the
push simply lagged the report by about two minutes.** Kept in full
rather than deleted, because the sequence is worth more than the tidy
version. At **17:00:04Z** the remote tip was `4432f967e3` with an
18-file diff and ⛔ none of the three files below; the dev's report
already described them at head `5911c8cf`. The seat held the review and
struck this paragraph. At **17:02:28Z** `git ls-remote` reports the tip
as **`5911c8cf664f534823d598c801f852c346be8075`** — `5911c8cf
docs(spec): the connector header and SYNC_ARCHITECTURE describe the
implemented behaviour` sitting on top of `4432f967` — **21 files**, all
three present. ⇒ the 17:00Z reading was **true when taken** and is now
superseded; the report was accurate about content and early about the
push. ⭐ The rule that survives, and it is not 「the check was wasted」:
**`git ls-remote` is the authority and the PR object is not** — while
this was being checked the PR object was still serving the stale 18-file
count. ⛔ A conclusion drawn from a summary face has a shelf life; one
drawn from the ref does not.

**The teaching text objectstack-ai#18794 narrowed is corrected to describe the
implemented behaviour** — `packages/spec/docs/SYNC_ARCHITECTURE.md` in
**five** places, plus the `connector.zod.ts` header TSDoc it renders
from (`content/docs/references/integration/connector.mdx` follows by
`gen:docs`, ⛔ never hand-edited). Those passages asserted the keys were
「declared but currently unimplemented」 and that
`ConnectorProviderContext` could never carry them; **both are now
false**. This is the ruling's third bullet, ⛔ not an absorption of
objectstack-ai#18794. ⭐ `health.circuitBreaker` and `connectionTimeoutMs` are
explicitly kept named as **still inert** in every corrected passage.

⚠️ ⭐ **Found by hand, ⛔ not by the drift bot — and it is the bot's own
declared blind spot doing exactly what it warns about.**
`SYNC_ARCHITECTURE.md` states the rule by its **inputs**, so it shares
no identifier with the emitter this diff changed and ⛔ no run could ever
have listed it. The bot's three named pages were each hand-verified and
**two were ACCURATE and left untouched** — `error-catalog.mdx`'s
`no_retry` is the API error-envelope enum from `api/errors.zod.ts`, a
different enum this diff never touches, and `jobs.mdx` is
`job.retryPolicy` from `shared/retry-policy.zod.ts`, likewise untouched.
The third was accurate too, and it is the one that falsified the code.

**Two interpretive calls, both stated rather than assumed:**

- 🔴 **`maxAttempts` counts TOTAL calls, the first included** — the
contrast `content/docs/automation/flows.mdx` already draws against
`maxRetries`, and it is what **corrected this implementation**. ⚠️
**Replaced by the seat 2026-09-20T17:00Z.** This bullet previously read
「counts **retries**, not total calls」, reasoning from `min(0)` and a
`shared/retry-policy.zod.ts` comment the dev has since said it
**over-read** (that comment is about opt-in vs opt-out defaults, ⛔ not
the counting base). The first reading reached a pushed commit; it was
falsified by a **documentation page**, and the implementation was
changed to match the page — ⛔ not the other way round. New pin:
`maxAttempts: 3` must make **three** calls, ⛔ not four, the case that
tells the two readings apart. Ablation: restoring `+ 1` turns 3 mapping
tests red.
- `maxDelayMs` is applied **after** jitter. Jitter is additive, so
capping first would let a delay land up to 99ms above the declared
ceiling.

## The seat's assumption 4 is falsified, and that is the one thing the
ruling asked me to report rather than invent

`AbortSignal.timeout` **is** available (Node 22 or newer, which the root
`engines` field pins; already used at
`packages/drivers/driver-turso/src/turso-driver.ts`). The
connection-vs-request distinction is **not**.

A connector's call is a WHATWG `fetch`, whose only cancellation surface
is one `AbortSignal` over the whole operation; nothing in that interface
observes the connection phase separately. Bounding time-to-response with
`connectionTimeoutMs` would kill a slow-but-connected upstream that the
author meant to allow with a large `requestTimeoutMs` — breaking the
very promise the key makes. Node's undici exposes `connectTimeout`
through a custom dispatcher, which is Node-only and a new subsystem
underneath every connector: the same ruling forbids it.

So `connectionTimeoutMs` is **carried** onto `ConnectorProviderContext`
(a custom provider on a transport that can separate the phases may
honour it) and **not enforced** by the platform.
`packages/spec/liveness/connector.json` keeps that one row `dead`, with
the measurement written into it, and a pin in
`connector-fetch-policy.test.ts` goes red if anyone aliases it onto
`timeoutMs`. **Nine of the ten rows flip, not ten.** The tenth is owed a
second, narrower ADR-0049 decision — see the acceptance notes.

## Verification

Full pipeline at the final commit `956fdb10`.

**Gate family**, re-derived in this worktree from the real changed paths
(`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`), every command run with its exit code
captured before any pipe, then reconciled with `--ran`:

```
dispatch-gates --ran: 86 derived family(ies) accounted for — 83 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3).
```

The three NOT MEASURED are `PREREQUISITE NOT MET`, not findings:
`check:dual-build-cjs-loads` and `check:type-check-debt` both need a
whole-repo build closure (CI builds it before those steps), and
`check-plugin-teardown-shape.mjs --self-test` cannot reach its
commit-pinned positive control on a shallow clone. All three are CI's to
run.

**Tests** (all on the merged tree):

```
@objectstack/spec                503 files / 14711 tests  passed
@objectstack/service-automation  140 files /  1676 tests  passed
@objectstack/connector-openapi     4 files /    34 tests  passed
@objectstack/connector-rest        4 files /    24 tests  passed
@objectstack/connector-mcp         3 files /    23 tests  passed
@objectstack/connector-slack       3 files /    10 tests  passed
```

`typecheck` green for all six. `pnpm --filter @objectstack/spec
check:generated`: 15 of 15 artifacts up to date (`api-surface/` and
`export-origins/` regenerated after a real build — the two new exports
plus the `ResilientFetchOptions` re-export).

**Lint — measured whole, not narrowed.** `eslint . --no-inline-config
--format json` over the repo root: **6939 files, 0 errors, 0 warnings**,
exit 0.

**Ablation — both pins proved able to fail**, through
`scripts/ablation-replace.mjs` (anchor must hit, blob hash must move,
restore proved against `HEAD`):

| mutation | result |
|---|---|
| `connector-fetch-policy.ts`: invert the early return so a declared
`retryConfig` is never mapped | 9 of 10 mapping tests RED, restored blob
== HEAD |
| `rest-provider.ts`: stop passing `ctx.retryConfig` into the connector
| 4 of 5 provider retry pins RED, restored blob == HEAD |

The pins assert **call counts and delay sequences**, not the presence of
a field: a pin on the def's `retryConfig` could not have failed here,
because the key was already storable and served back before any of this
landed. The sharpest one is the narrowing case — an authored
`retryableStatusCodes: [429]` must leave a 500 unretried, which only
passes if the authored list is the one executed (500 is retryable under
both the wrapper's own default and the schema default).

## Acceptance notes

**To file (class (c), an authoring trap that survives this PR):**
`connector.connectionTimeoutMs` still parses, still stores, is still
served back by `/meta/connector`, and is enforced by nothing — for the
measured reason above, which is a property of `fetch`, not an omission
here. It now needs a decision this card's ruling did not answer: retire
it, or re-describe it as something the platform can enforce (its sibling
`requestTimeoutMs` already is). Reproduction: declare a `connectors:`
entry with `provider: 'rest'` and `connectionTimeoutMs: 1000`, point
`providerConfig.baseUrl` at an endpoint that takes 5s, and dispatch the
`request` action — it completes normally. Dedupe words:
`connectionTimeoutMs declared unenforced` · `connector connect timeout
AbortSignal fetch` · `ADR-0049 connectionTimeoutMs second decision` ·
`connector.json connectionTimeoutMs dead row` · `retire or redescribe
connect timeout`.

**Fixed in place, declared here rather than filed:**
`connector-openapi`'s generated actions went through a naked `fetch` —
unbounded, never retried, and the one built-in HTTP path an authored
policy could never reach. It is the same defect on the same measured
site as this card's, the fix is mechanical and its shape was already
pinned by two sibling connectors, no other open PR holds the file, and
it adds no new gate family. Those actions now go through the same
wrapper as `connector-rest` and `connector-slack`. Evidence:
`openapi-connector.ts` `createOpenApiConnector` — `doFetch(url, init)`
became `resilientFetch(url, init, fetchOptions)`;
`@objectstack/connector-openapi` 34 tests still pass.

**Noted, not filed:**

- `ConnectorProviderContext.icon` and `.type` are set by the
materializer and read by none of the three shipped provider factories,
so an authored `icon:` or `type:` on a declarative instance never
reaches `GET /api/v1/automation/connectors`. Already recorded per-row in
`packages/spec/liveness/connector.json`, with what is owed already
stated there. Next toucher: whoever adds or changes a provider factory.
- `connector-slack` ships no provider factory, so nothing authored can
reach it — only the plugin door, hand-wired by its host. Not silent: an
unknown `provider` is a loud, named boot failure that lists the
installed ones. Next toucher: whoever adds a `slack` provider key.
- The `connector-rate-limit-config-removed` comment in
`packages/spec/src/conversions/registry.ts` says `retryConfig` and the
timeouts "are live". It was wrong when written (the ledger's
`retryConfig.strategy` row corrects it by name), and this PR makes nine
tenths of it accidentally true. A stale comment, no behaviour. Next
toucher: whoever edits that conversion entry.
- `health.circuitBreaker`'s sub-keys are `dead` on the same schema and
the same ADR-0049 worklist. Out of this card's scope by the card's own
words ("本卡只管这三个"), and its teaching text was already stanched by objectstack-ai#18983.
Next toucher: the next ADR-0049 connector sweep.
---

## Round 2 — both at-tier FAIL items fixed, at head `4a9b3480f2`

Review record `5751411253` FAILed this PR on two items. Both are fixed,
pinned and ablated; ⛔ nothing else was widened, and the two optional
notes the review offered (the `.describe('Maximum retry attempts')`
counting-base wording, and the pre-existing
`Retry-After`-on-any-retryable-status and unbounded-body-read
observations) were **deliberately not acted on**.

### FAIL 1 — the openapi routing is now pinned

The review's ablation proved this PR's own justification false: 「shape
already pinned by two sibling connectors」 did not hold for **this file**
— restoring the naked fetch left **34/34** openapi tests green.

Two cases added in `openapi-provider.test.ts`, through the factory with
`retryConfig` on ctx, mirroring `rest-provider.test.ts`: scripted fetch
`[503, 200]`, `{strategy: 'fixed_delay', maxAttempts: 2, initialDelayMs:
100, retryableStatusCodes: [503], jitter: false}`, asserting **exactly
2** upstream calls and a 200. The review's own ablation reproduces on
the same blobs (`a0172843ccb9` → `22b1470c9170`) and now turns the retry
pin **RED** where it measured 34/34 green; restore proved blob == HEAD.

⚠️ **Precision, stated rather than glossed: only 1 of the 2 new cases
discriminates.** The narrowing case cannot — with a naked fetch nothing
retries, so 「1 call」 is what **both** trees produce. It is kept for what
it pins, ⛔ not as a revert detector.

### FAIL 2 — `maxDelayMs` is now a maximum. Route (a), and the reason

The review offered two routes. **Route (a)** was taken: a `Retry-After`
longer than `maxDelayMs` now **ends the retry loop and returns the
response**.

⭐ **Why (a) and not (b):** this card exists to make a declaration equal
its enforcement, so making 「Maximum retry delay in ms」 **true** beats
documenting an exception to it. Route (b) would have left a key whose
*name* says maximum with an upstream-controlled way past it — which is
the exact shape **objectstack-ai#19410** was filed for earlier today.

The three alternatives, and why returning wins: sleeping it out makes
the key **not a maximum**; retrying sooner than asked is the abuse
`Retry-After` exists to prevent; **returning** hands the caller the real
status and its header. Only a `Retry-After` can reach that branch,
because `backoffMs` caps its own output — so the review's jitter-cap lit
control is untouched.

**Pinned** at `maxDelayMs: 1000` + `retry-after: 3600` → 1 call, the 429
returned, `sleep` **never called**, with a control that a `Retry-After`
**within** the ceiling is still honoured and still retried.
**Ablation**: deleting the guard (`bab28fcb1bde` → `9564b3126ef7`) turns
it RED; restore proved blob == HEAD.

⇒ the `retryConfig.maxDelayMs` ledger note **and** the changeset
sentence were both corrected, so ⛔ no artefact still claims a ceiling
the code ignores.

### Verification at the pushed head

Gate family re-derived on the pushed tip **and again** on the fix commit
— **identical 109 families** both times: **107 green / 2 NOT-MEASURED /
0 red / 0 unrun**. The two NOT-MEASURED are the shallow-clone self-test
and `check:dual-build-cjs-loads` needing the full build closure — ⛔ exit
3 is a prerequisite, ⛔ not a red. `check:generated` 15/15 with **no
regeneration owed** (route (a) moved no `.describe()`, so
`connector.mdx` did not move). Tests: openapi **36** (was 34), rest 26,
slack 10, spec wrapper+mapping 29. Full-repo lint 6,945 files, **0
errors, 0 warnings**.

⏹ ⚠️ **Overtaken and corrected 2026-09-20T20:37Z — the merge WAS
taken.** The paragraph below was true when written and is spent; kept
struck rather than deleted, because the reason it gave is the reason
round 3 exists.

> ~~`origin/main` has moved 12 commits since this branch's single merge
of record (`a88a9733`). It was **deliberately not re-merged**: the
at-tier record is keyed to a head sha, and a fresh merge creates a head
the record does not name. The merge is taken when the review is clear, ⛔
not while it is in flight.~~

---

## Round 3 — `origin/main` merged, the head re-reviewed, and the
base-drift cost paid

Once round 2 cleared, the base was **21** commits stale and the landing
needed a fresh CI run, so `origin/main` `576d5df6` was merged once as
**`13987f1b`**. ⛔ No rebase, ⛔ no amend, ⛔ no force-push, ⛔ no empty
commit.

**The merge is provably automatic**: `13987f1b` has exactly two parents
(`4a9b3480`, `576d5df6`), and `git merge-tree --write-tree 4a9b348
576d5df` yields tree `8350d10d`, which **equals** `13987f1b^{tree}` ⇒
no hand resolution existed. The two sides are disjoint — this PR 22
files, main 83, intersection **0** (lit control: both lists non-empty).

**This PR's own delta did not move**: 22 files, +1197/−132, the same
file list as before the merge.

**Neither guard was undone.** Both blobs are byte-identical to round 2,
and both ablations still give **exactly 1 red** — the openapi routing
pin (35 passed) and `maxDelayMs bounds a Retry-After by STOPPING` (18
passed, the within-ceiling and jitter-cap controls green by name).
Restores proved blob-equal to HEAD.

**Gates: 110 derived / 109 green / 1 NOT-MEASURED / 0 red / 0 unrun.**
One family *appeared* with the incoming commits
(`check:issue-citations`, wired in by `5a5e710f`); a true set comparison
against a round-2 derivation gives only-in-r3 = that one, only-in-r2 =
none. The NOT-MEASURED is `check-plugin-teardown-shape --self-test` at
exit 3 — a shallow-clone prerequisite, ⛔ not a red. `check:generated`
15/15 with no regeneration owed, the migration registry included after
main deleted ten entry files. Whole-repo lint 6,943 files, 0 errors, 0
warnings.

⭐ **Honest cost, stated because the reviewer found it and the dev's
number alone would have hidden it**: the first gate reading on a
turbo-cache-restored closure was **108 green + 2 exit 3**, not 109 + 1 —
`check:type-check-debt` refused on a dist whose mtimes predated its
sources, and reached exit 0 only after a real rebuild. Same conclusion,
named with what it cost.

**CI on `13987f1b`: 35 names, 33 success + 2 skipped, 0 failing, 0
pending** — all six `Test Core` shards green, including the 2/6 shard
that was red before. ⚠️ A green re-run **corroborates** that the earlier
red was not this PR's; it ⛔ does not prove it. The load-bearing evidence
is still the mechanism filed as **objectstack-ai#19424** (`80 × 0.25 s = 20 s` against
an observed 20,999 ms, the same titled assertion passing in 299 ms in
the same run).

⭐ **And one thing this PR previously could not establish, now
established from a different door**: `GET /branches/main/protection`
answers **403**, but `GET /rules/branches/main` answers **200** and
lists seven required contexts — `Test Core` among them, all seven
`success` here. **A 403 on one endpoint is a fact about that endpoint, ⛔
not about the question.**

**Round-3 at-tier review: PASS** — record `5752480809`, keyed to this
head. `check-clause2-carriers --pair 19388` exits **0** with zero ✗
rows, run *after* the record existed.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s from its displayed scale (objectstack-ai#19442)

Fixes objectstack-ai#19320

Clause-②: yes (widening)

⭐ Declared from the **measurement**, ⛔ not from the shape of the change:
the accept-set delta over 1950 cells is **36 ADDED / 0 REMOVED**, so the
narrowing arm is empty. ⚠️ The seat rewrote this line from a backticked,
prose-trailing spelling that the repo’s own `readClause2Line` reads as
`{kind: near-miss, reason: describing}` — a near-miss is ⛔ not a
declaration, and `Check Changeset`’s level axis would have had no input
from this body.

✅ **Both halves of the ruling are now here.** The behaviour half (the
validator's percent arm) and the `packages/spec` docblock half landed in
the same branch; the docblock half needed a file outside the boundary
this card was dispatched with, was reported rather than taken, and was
then authorized by the dispatching seat. The closing keyword is
therefore a closing keyword.

## The ruling this makes live

Maintainer ruling batch objectstack-ai#161 item 3 letter B (objectui#9810, comment
`5729749935`, 「其他同意」 2026-09-18T12:07Z). Quoted, not translated:

> - `packages/spec` `FieldSchema.scale` docblock (and the field
reference page): for `percent`, `scale` is the number of decimal places
of the percentage-point value as displayed and entered; stored precision
follows the storage scale (`fraction` ⇒ `scale + 2` places; `whole` ⇒
`scale`).
> - `record-validator.ts` `max_scale` branch: when `def.type ===
'percent'` and `percentScaleOf(def) === 'fraction'`, compare against
`def.scale + 2`; a pin per storage scale (fraction `scale: 2` accepts
`0.1234`, refuses `0.12345`; whole `scale: 2` unchanged).

## Premise re-verification — first-hand, on today's tip, with a lit
control

The card's premise was second-hand. Both halves were re-measured against
`origin/main` at base `24162f95`, through the **real** record validator
imported from the built `dist` of `@objectstack/objectql` — no harness,
no source shortcut.

| case | ruling B requires | measured BEFORE this PR |
| --- | --- | --- |
| fraction `scale: 2`, write `0.1234` (scale+2 places) | ACCEPT |
**REFUSE** `max_scale` `{scale:2, actual:4}` |
| fraction `scale: 2`, write `0.123` (scale+1) | ACCEPT | **REFUSE**
`max_scale` `{scale:2, actual:3}` |
| fraction `scale: 3`, write `0.33333` (the ruling's 33.333%) | ACCEPT |
**REFUSE** `max_scale` `{scale:3, actual:5}` |
| fraction `scale: 0`, write `0.33` | ACCEPT | **REFUSE** `max_scale`
`{scale:0, actual:2}` |
| fraction `scale: 2`, write `0.12345` (scale+3) | REFUSE | REFUSE
(agrees) |
| whole `max: 100, scale: 2`, write `12.34` / `12.345` | ACCEPT / REFUSE
| ACCEPT / REFUSE (agrees) |

**Lit control, same instrument, same run** — so the refusals above are a
reading and not a dead instrument: the validator ACCEPTED `0.12` and
`0.5` on the same field, and REFUSED for three *different* reasons —
`max_value` on `max: 1` with `5`, `min_value` on `min: 0` with `-0.5`,
and `invalid_number` on `'abc'`. `number` / `currency` / `slider` all
refused at `scale + 1` in the same run.

Docblock half, read at source: `FieldSchema.scale`'s `.describe()`
(`packages/spec/src/data/field.zod.ts`) states the 0-100 platform
ceiling and nothing about `percent`; `percentScaleOf`'s docblock
(`packages/spec/src/data/percent-scale.ts`) states the fraction/whole
rule and says nothing about `scale`. **Both halves of the card hold. The
premise is TRUE.**

## Accept-set delta — measured in BOTH directions

Both predicates (current and ruled) were run over an exhaustive corpus
of 1,950 cells: 5 numeric field types x 5 `max` declarations x 6 `scale`
values x 13 decimal-place counts.

```
corpus cells: 1950   unchanged: 1914   ADDED (accept set grows): 36   REMOVED (accept set shrinks): 0
declaration classes whose allowance moves: percent max=undefined, percent max=0.5, percent max=1
```

- The narrowing arm is **empty** — 0 of 1,950 cells. Nothing that writes
today stops writing; no stored value is re-read; no migration is
implied.
- Only fraction-stored `percent` moves. `percent` with `max` above 1,
and `number` / `currency` / `slider` / `rating` at every `max`, are
byte-identical in verdict.
- ⇒ `Clause-②: yes (widening)` is what the measurement supports. It was
dispatched as a claim to check; the claim survives the check.

⚠️ **One flag for the contract review, not a re-adjudication.** The
ruling's own Execution section declares `Clause-②: no`. The mechanical
criterion in `pm-dispatch` is 「本卡放宽接受集或扩大公开面吗」, and the accept set is
measurably relaxed, so the conservative routing arm is `yes`. The
declaration is by design provisional (「按设计临时…⛔ 非终审」), so this is a
routing difference to be recorded at review, not a change to the ruling.

## What this PR implements

`packages/objectql/src/validation/record-validator.ts` — the `max_scale`
branch gains its percent arm. The fraction/whole split is **read from
the spec's `percentScaleOf`**, not re-derived from `max` at this seam,
so the edit widget, the analytics wire and the validator keep answering
from one source.

The refusal envelope now reports the allowance that was **applied**: on
a fraction-stored `scale: 2` field, `0.12345` is still refused and
reports `constraint: { scale: 4, actual: 5 }`. Reporting the raw
declaration beside a stored fraction's place count would render "must
have at most 2 decimal places (got 5)" on a field that accepts four — a
true refusal described by a false constraint. This is the one detail the
ruling's letter leaves open; it is decided in the direction that keeps
the machine-readable surface honest, and it is pinned.

## The spec half — and the boundary that gated it

The ruling's first bullet is the `packages/spec` `FieldSchema.scale`
docblock **and the field reference page it generates**. That is
`packages/spec/src/data/field.zod.ts`, which was **outside** the
four-file boundary this card was dispatched with, so it was reported
before being touched rather than taken quietly. The two `packages/spec`
paths the dispatch originally named (`numeric-column-representation.ts`
and its test) are about the **DDL column** (`numeric_precision` /
`numeric_scale`) and mention `percentScaleOf` only inside a prose
comment — they are not part of this repair and are untouched.

What landed, after the seat authorized the corrected surface:

- `packages/spec/src/data/field.zod.ts` — `FieldSchema.scale`'s
`.describe()` now states **both** meanings: what the number counts on a
`percent` field (decimal places of the displayed percentage-point value)
and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒
`scale`, every other numeric type ⇒ `scale`). Both halves go in the
**describe**, not only in a source comment, because the reference page
is generated from the describe and an author who reads only that page is
the author the ruling is about.
- `content/docs/references/data/field.mdx`, `data/object.mdx`,
`system/migration.mdx` — regenerated by `pnpm --filter @objectstack/spec
gen:schema && gen:docs`, ⛔ never hand-edited. **Exactly those three
tracked files moved and nothing else**, which is what the pre-commit
source/regeneration split was arranged to make legible: the source edits
were committed first, so the regeneration commit's file list is the
regeneration's own output.
- `packages/spec/src/data/percent-scale.ts` — the optional
cross-reference, **taken**. The card's own measurement table named
*this* docblock as the one silent about `scale`, and `percentScaleOf` is
the function the validator calls, so a reader who lands here should find
the consequence rather than re-derive it. Written as a pointer, ⛔ not a
second copy: the rule is stated once on `FieldSchema.scale` and enforced
once in the validator.

**Serial constraint re-measured for those paths** before any of it was
written, same instrument as the dispatch used: 20 open PRs, `GET
/pulls/N/files` each, **0 unreadable file lists**, and no open PR
holding any of the eight paths. Lit control on the same run:
`packages/spec/src/**` matches 7 open PRs (objectstack-ai#19398, objectstack-ai#19374, objectstack-ai#19373,
objectstack-ai#19335, objectstack-ai#19314, objectstack-ai#19090, objectstack-ai#18319), so the zeros are absences the
instrument could see.

## Verification

**Ablation** — `scripts/ablation-replace.mjs`, anchor `? def.scale + 2`
in the production file.

- ⚠️ The **first attempt was a no-op and its reading is void**: the
replacement string was a prefix of the anchor, so its occurrence count
could not rise, and the tool refused before running anything. Recorded
rather than quietly retried.
- Second attempt landed: anchor `x1 -> x0`, blob `2e2d3981d29a ->
7b082941b44f`, command executed, restore proved `blob == HEAD
(2e2d398)` with `git diff HEAD` empty.
- Under ablation: **8 failed / 100 passed (108)**. All 8 are in the new
block and fail for the right reason — the fraction-stored writes are
refused with `max_scale`, and the envelope/message read `2` where the
ruling requires the applied `4`.
- ⭐ **5 of the 13 new cases cannot discriminate, and are not counted as
evidence**: the scale+3 refusal, the whole-percent pin, the
other-numeric-types controls, the other-refusal-reasons control and the
no-declared-scale control pass on both trees **by design** — they are
anti-vacuity and lit-control pins, there so that a branch which simply
stopped enforcing `scale` on percent fails this block too.

**Gate family** — derived from the real changed paths with `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, every command run with its exit code
captured before any pipe, reconciled with `--ran` carrying the recorded
codes:

```
111 derived · 111 run · 107 green · 0 red · 4 NOT MEASURED · 0 unrun
```

- One real red was found and repaired in this PR:
`check:error-code-casing` read the envelope pin's bare `code:
'max_scale'` as an ADR-0112 D1 emission because its recognizer window
held no field-addressed neighbour. Naming the field is the repair and a
stronger assertion. Re-run exit 0: "no unlisted lowercase error codes in
6371 scanned file(s)".
- NOT MEASURED, each with its reason, none of them a red:
- `check:dual-build-cjs-loads` — exit 3, PREREQUISITE NOT MET: reads
built output, 66 packages have no `dist/` in this worktree.
- `check:type-check-debt` — exit 3, PREREQUISITE NOT MET: needs the
whole workspace closure built.
- `check-plugin-teardown-shape.mjs --self-test` — exit 3: its
positive-control fixture is pinned to a commit this shallow clone cannot
reach.
- `check-engine-split-ratio.mjs --days 90` — exit 2: refuses to compute
an ADR-0076 D7 ratio on a shallow clone whose oldest visible commit sits
inside the window. Counted as "run" by the reconciler (exit 2 is not the
prerequisite code) but it measured nothing, so it is reported here as
NOT MEASURED.
- One gate **refused its prerequisite while spelling it `exit 1`**:
`check:skill-examples` reported that `packages/client-react/dist` held
no declarations, which is a refusal and ⛔ not a finding. Rather than
report it as NOT MEASURED, the package closure was built and the gate
re-run to a real verdict: exit 0, **258 prose examples type-check across
3 surfaces**, including the 10 spec-source TSDoc blocks — the surface
this PR edits.
- The two remaining `exit 3` families were **deliberately left
unmeasured**: both need a whole-workspace build, and both are whole-tree
families unrelated to a describe string and a validator arm. CI builds
everything and measures them there. The `check:skill-examples` closure
was built because that gate reads the spec source surface this diff
touches — the choice is principled, ⛔ not a budget.
- The reconciler's own verdict, DERIVED from the recorded codes rather
than claimed: `111 derived famil(ies) accounted for — 108 run, 3
NOT-MEASURED (3 DERIVED from a recorded exit 3)`.

**Suites and lint**, at the final commit:

- `pnpm --filter @objectstack/objectql test` — **303 files / 5050 tests
passed**, exit 0.
- `pnpm --filter @objectstack/spec test` — **505 files / 14,752 tests
passed**, exit 0; `pnpm --filter @objectstack/spec typecheck` exit 0.
- `pnpm --filter @objectstack/objectql typecheck` — exit 0;
`check:test-typecheck` OK, ledger unchanged at 40 files / 234 errors /
65 pinned signatures.
- `pnpm --filter @objectstack/spec check:generated` — exit 0, **all 15
generated artifacts up to date** against the edited `FieldSchema.scale`.
Both gates the ruling's docblock half puts at risk are green by name:
**`check:docs`** (the three regenerated reference pages) and
**`check:authorable-surface`** (authorable surface + JSON schemas).
`authorable-surface.base.json` did not move — a regular build never
writes it.
- `pnpm lint` — repo-wide `eslint . --no-inline-config`, exit 0 at
`bb9f9274`, the final commit. The full union ran; no narrowing was
needed, so no narrowing is claimed.
- **The ablation reading still describes the shipped file**: `git
hash-object packages/objectql/src/validation/record-validator.ts` is
`2e2d3981d29a…`, byte-identical to the blob the ablation restored to, so
nothing landed on the production file after it was proved able to fail.

**Import-side pins**: the public surface of `@objectstack/objectql` is
byte-unchanged (no export added, removed or retyped), so only behaviour
could move a consumer pin. Every non-`objectql` test file mentioning
`'percent'` was checked for a co-occurring `scale`; the six hits are
`packages/spec` schema tests and two `service-analytics` wire tests,
none of which exercises the record validator. The grep returning six
files is its own lit control.

## Acceptance notes

Noted, not filed — neither meets the three filing classes, and the
carrier for each is named:

- The `max_scale` message template
(`packages/spec/src/system/validation-message.ts`) reads "must have at
most N decimal places", which on a fraction-stored percent now describes
the STORED fraction rather than the number the author typed. It is
accurate and it is not what the author sees in the widget. Whether a
percent-specific sentence is wanted is a display decision that belongs
with the ruling's author, not a defect. Carrier: the contract review on
this PR.
- `packages/spec/src/data/numeric-column-representation.ts` already
carries an accurate prose account of the fraction storage rule in its
`percent` entry. It is documentation of the column, not of `scale`, and
needs no change under this ruling. Carrier: none needed — recorded so
the next reader does not re-derive the same dead end.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
objectstack-ai#19374)

Fixes objectstack-ai#19071

Clause-②: no

The runtime filter door now refuses a blank `$between` endpoint, exactly
as the authoring schema door already does.

## The split this closes

`RANGE_ENDPOINT_DESCRIPTION` — the published endpoint contract shared by
both of `$between`'s bounds — has stated since 2026-09-17 (objectstack-ai#18012, batch
objectstack-ai#146 item 5 letter A) that "BOTH are required NON-BLANK: an empty
string, null and undefined are refused, and the refusal names the blank
side". That rule shipped at the schema door only.

Re-measured on this branch's base `2277d1fcd` at 2026-09-20T13:22Z, all
three of the card's probes reproduce, control included:

| probe | before this PR |
|:--|:--|
| `parseFilterAST({ at: { $between: ['', ''] } })` | returned unchanged
— same object reference |
| `parseFilterAST({ at: { $between: ['2026-01-01', ''] } })` | returned
unchanged — same object reference |
| control `parseFilterAST({ at: { $between: [null, 1] } })` | threw
`Operator "$between" on field "at" requires two non-null bounds` |

One published sentence, two truth values. The door that passed it is the
one an embedder reaches by handing a lowered filter straight to a
driver, where the range stops bounding on the blank side while still
reading as a complete two-element range.

## What this changes

`assertListComparandShapes`' `$between` arm refuses `''` and `undefined`
at either bound with `INVALID_FILTER` / 400, naming the blank side (MIN
or MAX plus the index) and carrying the schema door's own two
prescriptions — write the bound you meant, or drop `$between` for
`{"$gte": min}` / `{"$lte": max}` if only one side was ever bounded. The
longest assembled form measures 466 characters against the unrelaxed
500-character client bound, and the bound test grew the new cases.

Order inside the arm is arity, then `null`, then blank, so a pair that
is blank on one side and `null` on the other keeps the message it has
had since 2026-08-31.

## ⚠️ One ruled word could not be implemented as written — please read
this cell

The ruling says the runtime door refuses a blank endpoint "exactly as
the schema door does (empty-after-trim string, `null`, `undefined`)".
Those two halves disagree, and the parenthetical is the one that does
not hold: **the schema door does not trim.**

| endpoint pair | schema door on `2277d1fcd` | this PR's runtime door |
|:--|:--|:--|
| `['   ', 'M']` (whitespace-only) | `success: true` | passes |
| `['\t\n', 'M']` | `success: true` | passes |
| `['', 'M']` | refused, names MIN | refused, names MIN |

`rangeEndpointSchema`'s own comment states the rule as `endpoint !== ''`
and says in as many words: "⛔ Not a trim and not a whitespace rule: the
ruling is the empty string, and widening it here would narrow a
published face further than ruled." `filter.test.ts` then pins
`RangeOperatorSchema.safeParse({ $between: [' ', 'M'] })` green on
purpose, with the comment "this assertion is what keeps a later reader
from widening it without a ruling of their own".

So an empty-after-trim predicate here would have re-opened the very
split this card exists to close — in the opposite direction, with
whitespace passing the authoring door and being refused one step later —
and narrowed a published face further than any ruling has. This PR
implements the operative clause ("exactly as the schema door does", "the
same guidance as the schema door") and leaves whitespace-only endpoints
legal at both doors. ⛔ Nothing is re-adjudicated: if the intent really
was to trim, that is a second narrowing of a published face and wants
its own ruling, and it is one line here plus the `filter.test.ts` pin on
the other side.

## Scope held, deliberately

- ⛔ `RANGE_ENDPOINT_DESCRIPTION` and
`packages/spec/src/data/filter.zod.ts` are not touched — B was refused,
and that file is held by open PR objectstack-ai#19335.
- ⛔ No ADR-0087 transition is registered — C was refused. The
changeset's disposition is `already-registered` against objectstack-ai#18012's
existing entry, which covers this same surface set.
- ⭐ Only the `$between` row of the `filter-comparand-shape.test.ts` pin
is inverted. The `$in` / `$nin` falsy-member rows stand, in place, with
a note saying which row moved and why: falsy VALUES are values, and this
ruling is about range ENDPOINTS. The replacement pin reads BOTH doors
and asserts they agree, rather than restating either.
- Pointer for objectstack-ai#13357: the null-shaped carve-out recorded there is
preserved unchanged — `null` bounds keep their own message and their own
prescription (the null predicate), and are checked first. This PR
narrows nothing that ruling settled; it adds a second,
differently-spelled blank alongside it.

## Verification

Every reading below was taken in this worktree at HEAD `7682aebc` (after
`git merge origin/main`), unless the line says otherwise.

- `pnpm --filter @objectstack/spec build` — green.
- `pnpm --filter @objectstack/spec test` — 502 files / 14696 tests
passed.
- `pnpm --filter @objectstack/spec typecheck` — green (`tsc --noEmit`,
scripts project, and the test-layer debt ledger held at 54 files / 259
errors / 144 pinned signatures).
- `pnpm --filter @objectstack/objectql exec vitest run
src/engine-filter-array-lowering.test.ts
src/engine-comparand-type-door.test.ts
src/query-expression-conformance.test.ts
src/protocol-explicit-filter-field-gate.test.ts` — 4 files / 289 tests
passed. First attempt was a PREREQUISITE failure, not a red: the closure
was unbuilt and the run died on "Failed to resolve entry for package
@objectstack/core". Built `@objectstack/objectql^...` and re-ran.
- Gate family re-derived in this worktree from the real changed paths:
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` — 81 families at this head (75 before the
changeset existed). All 81 run with exit codes captured to disk before
any pipe; `--ran` reconciles 81 derived / 80 run / 1 NOT MEASURED / 0
UNRUN.
- The one NOT MEASURED is `node scripts/check-plugin-teardown-shape.mjs
--self-test`, exit 3: its positive control is pinned to commit
`621a487607881c66b2899b7e3477115229a156b4`, which this shallow checkout
cannot reach; `git fetch --deepen 500` did not bring it. That command
grades the checker's own fixtures, not this diff — the PR-relevant form,
`node scripts/check-plugin-teardown-shape.mjs`, exits 0.
- Three gates first answered exit 3 PREREQUISITE NOT MET on an unbuilt
tree (`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt`) and one hit my runner's own 150-second cap
(`check:query-options-erasure`). All four are green after `pnpm build`,
and `check:type-check-debt` re-measures 4 ledger entries / 53 raw
errors, none above its recorded number.
- `pnpm lint` — the whole repo, `eslint . --no-inline-config`, exit 0 at
`7682aebc`. No narrowing was claimed and none was needed.

## Acceptance notes

Noted while measuring, ⛔ not filed by this dispatch and ⛔ not fixed
here:

- **A `{ $field }` reference as a `$between` endpoint is refused by the
schema door and accepted by the runtime door.** The same two-door shape
as this card, one endpoint spelling over, and already ruled on the
schema side: `RANGE_ENDPOINT_DESCRIPTION` says "A { $field } reference
is NOT an endpoint shape", ruled 2026-08-11 under objectstack-ai#7596. Reproduction on
`2277d1fcd`: `RangeOperatorSchema.safeParse({ $between: [{ $field: 'a'
}, 'M'] })` answers `success: false`, while `parseFilterAST({ f: {
$between: [{ $field: 'a' }, 'M'] } })` returns the filter unchanged. It
is left alone here because its refusal needs its own wording and, being
a second narrowing of a published face, its own ruling — the same reason
this card exists. Dedupe words: `field reference between endpoint` ·
`parseFilterAST runtime door` · `filter-comparand-shape` · `7596
endpoint shape` · `two doors disagree`.
- **`@objectstack/hono` fails its dts build under `turbo run build
--concurrency=2 --filter='./packages/*' --filter='./packages/*/*'`**,
with `TS7016: Could not find a declaration file for module
'@objectstack/plugin-hono-server'`, and builds clean under `pnpm build`
on the same tree minutes later. Reads as a build-ordering race in a
shared warm cache rather than a defect in the tree; recorded because the
gate prescription for `check:type-check-debt` names that exact command.
Carries no reproduction that does not depend on cache state, so it is an
observation, not one of the three filing classes.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… closure, and the allow rules that let seats run it (ruling 「B(治本)」) (objectstack-ai#19479)

Fixes objectstack-ai#19469

Clause-②: no

## The ruling this lands under, verbatim

「B(治本):给具名脚本加一条 Bash 允许规则进 .claude/settings.json(受管面,走 PR
您合),此后席位的批量关闭不再靠分类器掷硬币。分诊席的 objectstack-ai#19292 加了 4 条规则但没覆盖这个形状。你负责派发」

A second ruled source touches the same file: batch objectstack-ai#204 item 4, letter A
(「204 同意」, recorded on objectstack-ai#19362) authorises the objectui spellings of the
two landing-endpoint allow rules. Those two entries are **not in this
diff** — see *Scope addition, refused mid-round* below. objectstack-ai#19362 is not
addressed here and remains open.

## The measured defect

Two seats, one wall, one day. A 90-card closing sweep spelled as a bash
loop over `post-stamped.mjs` → `label-write.mjs` → `PATCH /issues/{n}`
was refused by the session runtime's write classifier before any
request. The second seat took the same order (objectstack-ai#19458, execution log
5753693136): 90 of 90 cards passed its live gate, 77 were actionable,
**one** closed, and then a batch script, an inline three-card loop and a
*single* `post-stamped --comment=19243` were each refused — the
identical command shape that had just succeeded twice. It stopped rather
than grind a coin-flip channel across 76 three-step acts, because a
comment that lands without its label write is a half-state on the board.

Why the existing rules did not cover it: the two seat-write rules carry
`--use-env-proxy` **inside the prefix**, and seats invoke `node
scripts/pm/post-stamped.mjs …` (the tool re-execs itself with that
flag), usually behind a `cd … &&` compound. An allow rule is a prefix
match against the command *as typed*, so neither matched and every call
fell to the classifier. No rule named a batch shape at all.

## What lands

**1. `scripts/pm/close-cards.mjs`** — the three-step closure (comment ·
label · close) as one named command, ⛔ no new gate.

Per card, re-read live first, then SKIP and log on: not open · has an
assignee · carries `pm:retriage` · pm-state is not *exactly* the
expected label (default `pm:queue`; no state, another state and two
states all skip) · an open PR references it (`--skip-pr-referenced`,
default on). Otherwise: post the comment, remove the state label, close
with the `state_reason` — and read the close back, because a 200 whose
body does not say `closed`, or that records another reason, is not the
close that was asked for.

Stamping and the four-step label write are **reused, never
re-implemented**: post-stamped's write path is module-private
(`writeArtefact`/`main`), so it is driven as a child process with its
documented flags (`--repo=`, `--comment=N`, `--file=`, `--json`) and its
exit code read before any pipe; its exported pure half (`renderBody`,
`claimKeyedLineRefusals`) runs the comment pre-flight **once**, before
card one, rather than ninety times. The label step calls label-write's
exported `runLabelWrite` in-process with options built by label-write's
own `parseOptions`. The pm-state vocabulary is imported from
`check-half-states.mjs`.

A card whose comment landed and whose label write or close did not is a
HALF-WRITE: the run **stops at that card** and exits 4 naming it and
exactly which of the three writes landed. It does not continue and it
does not retry — continuing turns one half-state into a page of them.

Exits: `0` every non-skipped card landed all three writes · `2` usage ·
`3` PREREQUISITE NOT MET · `4` HALF-WRITE, the card is named · `5` the
platform refused a write.

**Which PR reading** — `GET /repos/{o}/{r}/issues/{n}/timeline`,
`cross-referenced` events whose `source.issue` carries a `pull_request`
and whose `state` is `open`. That endpoint is the one
`references/rest-channel.md` already declares reachable for
cross-references. ⛔ Not `/search/issues` (the egress proxy refuses
`/search/*` by design, so the default skip would be unavailable on
exactly the seats this tool is for) and ⛔ not a
`closed_by_pull_requests`-style signal, which answers "which PR would
close this" — narrower than "an open PR references it", and it would
pass a card an open PR merely mentions.

**2. `.claude/settings.json`** — four `permissions.allow` entries:
`Bash(node scripts/pm/close-cards.mjs *)`, `Bash(node --use-env-proxy
scripts/pm/close-cards.mjs *)`, and the no-flag spellings of the two
existing seat-write rules, `Bash(node scripts/pm/post-stamped.mjs *)`
and `Bash(node scripts/pm/label-write.mjs *)`. `deny` is untouched; key
order and formatting unchanged.

**3. Usage** — in the script header, with the reason: invoke it **from
the repo root with nothing in front of `node`**, no `cd … &&` compound,
because the rule matches the command as typed.
`references/rest-channel.md` gets **no** line: `pnpm
check:pm-skill-ratchet` reports that file at 82 lines against a ceiling
of 82 — headroom 0 — so the header carries it alone, exactly as the
card's item 3 provides for.

**Minimal registration**, stated as the card asks:
`check:pm-close-cards` in the root `package.json` and one step in
`lint.yml`, beside the identical pair for post-stamped and label-write.
Without it the new self-test would ship unrun by CI, which is the state
`check:self-test-wired` exists to prevent — it now counts 220 scripts
and this one is in the population.

## A defect this found in its own first reading

The first dry run over the 90 cards exposed a truncation in the script's
own timeline read. Measured: of those 90 cards, **objectstack-ai#13799 carries more
than 100 timeline events**, so a single `?per_page=100` request returned
a truncated history at HTTP 200 with nothing saying so — and a
cross-reference on page 2 reads exactly like no cross-reference at all,
i.e. the open-PR skip answering "no" for a card that has one.

Fixed in the second commit: `readTimeline` walks by **page number**
until a short page (the spelling `references/rest-channel.md`
prescribes, cursor exhaustion having been measured on this platform to
stop early), and a card still returning full pages at the 30-page cap
**stops the run** rather than deciding on what it managed to read. The
fake board pages for real, so the truncation case is driven rather than
modelled.

## Verification

**`--self-test`** — `node scripts/pm/close-cards.mjs --self-test`, exit
0: `OK close-cards self-test: 102 cases pass across 11 batteries —
offline, no network, no token.` Battery roster, per-battery floor and
the verdict handshake all copied from the landed shape in
`label-write.mjs`.

**Three ablations, each with the mutation proved on disk and the restore
proved byte-identical** (`scripts/ablation-replace.mjs`, blob
`a04f59d49673` before and after every leg, `git diff HEAD` empty):

| leg | mutation | reading |
|---|---|---|
| A — a rule | delete the `pm:retriage` skip | blob `a04f59d49673` →
`f455f723528c`; self-test RED, 1 of 102, naming that case |
| B — the handshake | `return 0` before the verdict | blob →
`44141e535b3c`; dispatch refuses, exit 1, "selfTest() returned without
reaching its verdict" |
| C — the floor | delete one assertion | blob → `d0d6a6169a2b`; RED with
0 case failures and 1 floor problem, naming the battery that fell 8 → 7
|

**`--dry-run` over objectstack-ai#19458's 90 numbers** (a READ; it wrote nothing, on
any card), re-run at `901b26ea` after the pagination fix:

```
close-cards: DRY RUN — nothing will be written. objectstack-ai/objectstack · 90 card(s) · reason `not_planned` · expect-state `pm:queue` · open-PR skip ON
objectstack-ai#19408 SKIP an open PR references it (objectstack-ai#19445)
objectstack-ai#19325 SKIP not open (state closed/not_planned)
objectstack-ai#19146 SKIP has an assignee (`os-steve`) — somebody owns it
objectstack-ai#19240 SKIP an open PR references it (objectstack-ai#19335)
close-cards: DRY RUN — nothing was written. 90 read · 86 actionable · 4 skipped
```

**86 actionable / 4 skipped**, against the card's 77/13 read at 00:01Z.
The card provides for the move; the move is measured rather than
assumed. Probing every cross-referenced PR on the 86: **11 of those
cards had their referencing PR close after 2026-09-21T00:00Z** — objectstack-ai#19440,
objectstack-ai#19404, objectstack-ai#19396, objectstack-ai#19395, objectstack-ai#19390, objectstack-ai#19343, objectstack-ai#19336, objectstack-ai#19319, objectstack-ai#19309, objectstack-ai#19179
and objectstack-ai#18375, ten of them on PR objectstack-ai#19456 alone, closed 00:34:28Z. Of the
remaining two, objectstack-ai#19325 is the one card the triage seat closed before it
stopped, and objectstack-ai#19146 has since gained an assignee. The matrices agree;
the board moved.

**Gates** — derived with `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` against the diff (68 commands, the
list identical before and after the second commit), each exit code
captured before any pipe. Green includes
`check:pm-settings-deny-roster`, `check:pm-widening-tells` (and the real
diff judged with `--declaration no --diff`: no tell, "no declared
surface covers it (4)"), `check:pm-skill-ratchet`,
`check:self-test-wired`, `check:self-test-workflow-commands`,
`check:pm-dispatch-gates`, `check:nul-bytes` and `check:pm-close-cards`.
**`pnpm lint` (`eslint . --no-inline-config`) is green over the whole
repo at exit 0 — no narrowing, so no narrowing to justify.**

Six derived families answer **PREREQUISITE NOT MET — a built tree is
required** and are NOT MEASURED locally: `check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`, `check:type-check-debt` and the
lint package's `check:doc-formula-expressions`. Each reads `dist/`; this
diff contains no package source and produces no `dist/` byte, so it
cannot move any of them, and CI runs them on a built tree. ⛔ Recorded as
not measured, not as green.

## Scope addition, refused mid-round

A mid-round scope addition asked for two further `permissions.allow`
entries — the objectui spellings of the two landing-endpoint rules that
already exist for objectstack
(`.../objectui/pulls/*/ccr/ready_for_review` and
`.../objectui/pulls/*/ccr/auto_merge`), placed after their objectstack
twins.

**They are not in this diff.** Both attempts to write them were refused
by this session's own permission classifier with reason
`[Self-Modification]` — once through a scripted edit, once through the
editor tool — and a third route was not attempted. The working tree is
clean and nothing partial landed. This is the same classifier, on the
same file, that had permitted the four entries above earlier in the same
round: a third observation of the non-determinism this card was filed
for, now on a file surface rather than a write channel. A seat with a
channel adds those two lines, or the maintainer adds them at merge.

## Tier and landing

**Tier S by the register** (`GOVERNED_SURFACES` in
`scripts/pm/check-governed-merges.mjs`): the diff touches `.claude/**`.
Per the maintainer's directive above (「受管面,走 PR 您合」) **the maintainer
merges this by hand**. ⛔ This PR is not flipped to ready, not queued,
and auto-merge is not armed.

`Check Changeset` wants `skip-changeset`: no released package is
touched. The four paths are `.claude/settings.json`,
`scripts/pm/close-cards.mjs`, `.github/workflows/lint.yml` and the root
`package.json` (private, `@objectstack/spec-monorepo`, a `scripts` entry
only) — every one of them on the non-publishing fast track. The label is
the seat's to apply.

## Acceptance notes

Noted, not filed:

- `references/rest-channel.md` has **headroom 0** (82 lines, ceiling
82), and so does every other ceilinged file in that ratchet. The channel
table therefore cannot gain a row for this script without a ruled raise
or an equal deletion. Carrier: the next PR that raises that ceiling.
Observation, not a defect.
- `--dry-run` buys one card read per card and a timeline walk only for a
card that would otherwise be acted on. A future batch larger than this
one may want a `--json` summary for the completion comment; nothing
needs it today. Carrier: none.

## 维护者速读(草稿)

**改了什么** — 新增一个具名脚本 `scripts/pm/close-cards.mjs`,把「评论 · 摘标签 ·
关卡」这三步合成一条可被允许规则整条命中的命令;并在 `.claude/settings.json` 的 `permissions.allow`
里加了 4 条规则(这个脚本两种拼写,加上两个既有工具的无 flag 拼写)。`deny` 一个字没动。

**为什么改** — 批量关卡以前是 shell 循环,每一步都由会话的写分类器逐条判,判得不稳:同一条命令刚成功两次就被拒,90 张卡关到第
1 张就停了。停是对的——评论落了标签没落就是半状态——但代价是这批清理走不动。一条具名脚本 = 一条前缀,分类器不再掷硬币。

**风险与代价(含回滚)** — 风险最集中的一点是「半写」:脚本在第一张半写的卡上立刻停,退出码 4,并点名是哪张卡、哪几步落了,⛔
不继续、⛔ 不重试。回滚代价为零:删掉这个文件和那 4
行规则即回到今天,没有任何其它代码读它。另一项要请您留意的是,允许规则本身是放宽面——它放宽的是「跑本仓自己的三个 PM
脚本」,不是任何网络写端点。本轮还有 2 条 objectui 的规则被会话分类器当场拒写(见上节),不在这个 diff 里。

**席位意见** — (留空,待席位复审填写)

**你要做的** — 读一眼那 4 行允许规则是不是您想给的面,然后手工合。⛔ 本 PR 不翻 ready、不入队、不挂
auto-merge。合完之后,这批 90 张卡的关闭由分诊席跑一次 `--dry-run` 再跑一次实关,日志回贴 objectstack-ai#19458。

---
_Generated by [Claude
Code](https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…nance instead of a liveness test; the reader accepts it and C9 keeps one red (objectstack-ai#19502)

Fixes objectstack-ai#19240
Clause-②: yes

`Clause-②: yes` — the claim reader's accept set widens (a cross-login
`Release:` carrying provenance now retracts) and C9's judged set narrows
to a bare cross-login `Claim:`; a `.claude/**` surface ⇒ Tier S, the
seat lands it on its `## Contract review` PASS + `--pair` 0. This PR
stays draft.

## What lands — ruling 5754797404, shape A, executed as ruled

**The claim HANDOVER protocol.** A card whose claimant is unreachable
(token exhausted, session ended, identity retired) is taken over by a
new session in ONE comment, and the claim reader accepts that comment —
no liveness heuristic anywhere: the human's word, copied with
provenance, is the permission.

| # | Surface | Change | Net lines |
|---|---|---|---|
| 1 | `scripts/pm/check-clause2-carriers.mjs` | `claimRetractions` gains
the HANDOVER arm; `CLAIM_RETRACTION_RULE`, `CLAIM_HANDOVER_RULE`,
`CLAIM_HANDOVER_REMEDY` rewritten; C9 keeps one red and lists refused
handover attempts; self-tests both sides (1075 → 1091 cases) | +174 /
−54 = **+120** (the claim's budget, exactly) |
| 2 | `.claude/skills/pm-dispatch/SKILL.md` | :177 · :472 · :492 aligned
in place; the nine liveness-heuristic bullets (:493–:501 at base)
replaced by five handover bullets | 813 → **809** (net −4; ceiling 813,
headroom 4) |
| 3 | `.claude/skills/pm-dispatch/references/core-rules.md` | the two
twins (:110, :111) rewritten in place | 151 → **151** (net 0) |
| 4 | `.claude/agents/os-dev.md` | :94 in place: every compilable step
is pushed; a handover reads the remote branch's last sha | 403 → **403**
(net 0) |
| 5 | `scripts/pm/check-half-states.mjs` | **untouched** — measured:
`grep -n 'author !== '` → 0 hits; its `Release:` readers (H47
`latestMarkedComment` / `releaseAnswersClaim`) compare comment ORDER,
never authors, so it carries no copy of the retraction rule and imports
nothing from the clause-② reader | 0 |

Base `32b5831`, `origin/main` merged once at `d00692f` (PR objectstack-ai#19462 had
not landed at 2026-09-21T04:1xZ — SKILL.md ceiling stays 813, no region
overlap). Every line ≤ 120 bytes; `check:pm-skill-ratchet`,
`check:pm-skill-id-lint`, `check:pm-governed-prose`,
`check:agent-model-declared`, `check:nul-bytes` all exit 0 on the edited
files.

## 1. The reader

### The HANDOVER arm of `claimRetractions` (the one accept-set widening)

A `Release:` comment by a **different login** retracts an earlier claim
when, and only when:

- (a) its `Release:` **line** (the first line of the body that
`markerMatches(RELEASE_COMMENT_MARKER, line)` reads — the sibling's ONE
reading, applied per line, so `**Release:**` and `` `Release:` `` read
and `- Release:` does not) names the retracted claim's **comment id**
(digit-bounded) **and** its **session id** (token-bounded; the claim's
`Session:` line first, else the first `session_…` token in the claim
body — a claim with none cannot be named, fail closed);
- (b) the comment carries the three provenance fields of SKILL.md's 出处三件
line 「代执行他人指令的关闭、摘标、回收认领,评论带出处三件:谁的指令、原话、在哪说。」, each with a
**non-empty** value.

Missing any one piece ⇒ NOT a retraction, state unchanged. ⛔ No liveness
test: the earlier claimant's later comments are irrelevant (pinned).
Same-login retractions: byte-for-byte the old behaviour (no id, no
session, no provenance needed).

**Pinned key spellings** (`HANDOVER_PROVENANCE_KEYS = ['谁的指令', '原话',
'在哪说']`, exactly the :149 vocabulary — ⛔ no fourth key, ⛔ no synonym). A
field is: the key · optional decoration (`*`, `_`, backticks) · an
optional parenthetical `(…)` / `(…)` · a colon (ASCII `:` or fullwidth
`:` — indistinguishable on the page, pinned equal) · the value = the
rest of that line up to the next key, or, when that is blank, the
blockquote (`>` lines) under the key. Whitespace, `>`, decoration and
separator punctuation alone are an EMPTY value (pinned per key).

The two live specimens, both replayed verbatim in the self-test:

- 5754797404 (inline paragraph): `**出处三件** —
**谁的指令**:维护者,在本席(…)会话内的三个真实用户轮次。**在哪说**:本席会话聊天,在评论
5754717208(2026-09-21T02:44Z)之后、本条之前的连续三轮。**原话**(逐字,⛔ 未翻译、未润色):`
followed by the blockquoted turns.
- 5754717208 (line per field): `**出处三件**——` / `**谁的指令**:维护者(本仓
maintainer,…)。` / `**在哪说**:本会话聊天内,…。` / `**原话**(逐字,⛔ 未翻译、未润色):` followed
by the blockquoted turns.

### C9 keeps exactly one red

`claimHandovers` is unchanged in its walk: it reads the LIVE claims
through the same `claimRetractions` map, so a handover comment (①
provenance `Release:` naming the holder's claim + ③ new `Claim:` with
`Branch:`/`Clause-②:` in the SAME comment) leaves one author holding ⇒
no row, no note, and the new `Claim:` is the governing claim on the
`--pair` path (a comment is not later than itself, so it cannot retract
its own claim — pinned). The one red left: a cross-login `Claim:` with
NO `Release:` at all for the earlier claim — a real claim-jump.
`CROSS_AUTHOR_CLAIM_ROW_EFFECTIVE_AT` stays; its gating now applies to
that narrowed red only (it is read at the same place as before).

**Loud refusal, not silent red:** a cross-login `Release:` that TRIED to
hand over a live claim (names its id or session id, or carries a
provenance field) and did not is listed in the C9 sentence with its
reason — `missing 在哪说`, `the comment id is not on its `Release:` line`,
`the session id is not on its `Release:` line`, `the claim carries no
session id to name`. A bare `Release:` by another login (a seat
releasing its own claim) is not an attempt and is not listed — the first
draft listed those and the `--pair 19373` row named os-steve's own two
releases as "refused handovers" of os-bill's claim, which was noise;
narrowed.

**The remedy sentence** (`CLAIM_HANDOVER_REMEDY`) prescribes the
four-item handover comment and prints SKILL.md's handover sentence
**verbatim** (`CLAIM_HANDOVER_SENTENCE_LINES` = the five 认领 bullets,
byte for byte), citing the 出处三件 line as its source. The old remedy words
「the HOLDER posts `Release:` … the TAKER posts nothing until then … ⛔
never a `Release:` on the holder's behalf」 are gone; ② (assignee swap)
and ④ (the sha record) are stated as the seat's acts, unread by the
reader.

### Self-tests (beside the existing retraction and C9 cases, ⛔ not at
`selfTest()`'s tail; floor unchanged)

Retraction battery: ⭐ a cross-login provenance `Release:` naming id +
session is accepted — state `declared`, the handover's own `Claim:`
governs, the record says "a DIFFERENT login … HANDOVER" · ⛔ missing any
one field, or a key with an empty value ⇒ refused, one case per key each
way, the missing key named · ⛔ id without session / session without id /
both in prose under a bare `Release:` line / the three fields with no
`Release:` line at all ⇒ refused · ⭐ NO liveness test: the earlier
claimant commenting after the handover changes nothing · ⭐ both live
specimens' spellings read, and a fullwidth colon reads as the ASCII one
· ⛔ same-login `Release:` still needs nothing (arm untouched); a claim
with no session id cannot be handed over · ⛔ item ④ absent still
retracts (the seat's act, not the reader's gate) · the printed rule
names both arms, the three keys, the source line and the absent liveness
test.

C9 battery: ⭐ the handover comment clears C9 (no row, no note) · the
handover's `Claim:` is the governing claim on `--pair` (branch,
declaration) and is not self-retracted · ⛔ the same comment missing any
one field ⇒ still C9 JUDGED, the row names the refused release and the
missing key · ⛔ the ONE red kept: a cross-login `Claim:` with no
`Release:` at all · ⛔ a handover naming only one of two live claims
leaves the other standing · the remedy is SKILL.md's handover sentence
verbatim, with the 出处三件 source line and 让先到者 for a yield · each sentence
line is one SKILL.md bullet by shape (≤ 120 bytes, no bullet, no issue
id).

## 2. Before / after — every changed instruction line

`.claude/skills/pm-dispatch/SKILL.md`

| line (base → now) | before | after |
|---|---|---|
| :177 → :177 | `- dev 自己死了不等于维护者中止:子代理消失是正常死法,走死认领回收。` | `- dev
自己死了不等于维护者中止:子代理消失是正常死法,走接管(见认领节)。` |
| :472 → :472 | `- 共享身份下 assignee 只答有无认领;身份只认正文 session ID,⛔ 不认作者字段。` |
`- 共享身份下 assignee 只答有无认领;身份只认正文 session ID,⛔ 不认作者字段,接管同此。` |
| :474 | `- 释放是显式动作:让卡离手者同笔清 assignee + `Release:` 行(会话/因/去向);下一任重新认领。`
| **unchanged, deliberately** — this line is the greppable source of
`RELEASE_ACT_RULE` in `check-half-states.mjs` (outside this claim's
surface); the handover reuses the act's two halves (② assignee + ①
`Release:` line, by the taker), stated in the new bullets |
| :492 → :492 | `- dev 侧早推分支,远程分支是在飞工作最硬的证据。` | `- dev 每个可编译小步即
push:容器随会话回收,未 push 的树救不回,可交接的只有远程分支。` |
| :493–:501 → :493–:497 | the nine liveness bullets (listed in §3) | `-
认领人不可达(token 耗尽/会话结束/身份退役)⇒ 接管:一条评论四件齐,⛔ 不判死活。` / `- ① 跨账号 `Release:`
点名被撤认领的 id 与 session ID,带出处三件(谁的指令/原话/在哪说)。` / `- ② assignee
同笔换人(`--unassign 旧 --assign 新`);③ 新 `Claim:`:新 session、续用分支与远程 sha。` /
`- ④ 交接记录:旧分支最后已 push 的 sha + 一句状态;读者只验①③形状,缺一件即非撤销。` / `- C9 只剩一种红:无任何
`Release:` 的跨账号 `Claim:`(真抢卡);线程上每条活认领都要点名。` |
| :502 → :498 | `- 误伤活席位 ⇒ 令其追加式更正,落 PR 正文不落分支历史。` | unchanged (a
mis-handed live seat still appends its correction) |

`.claude/skills/pm-dispatch/references/core-rules.md`

| line | before | after |
|---|---|---|
| :110 | `- 更早的他会话认领即让行并交出已诊断的一切;认领逾一天且无合并证据即疑死。` | `-
更早的他会话认领即让行并交出已诊断的一切;认领人不可达即接管,⛔ 不判死活。` |
| :111 | `- dev 自死不等于维护者中止,需显式信号;回收前先救工作树,有提交的活分支 ⛔ 永不回收。` | `- dev
自死不等于维护者中止,需显式信号;接管一条评论四件齐,只救已 push 的分支。` |

`.claude/agents/os-dev.md`

| line | before | after |
|---|---|---|
| :94 | ` - 有可展示内容即 commit、push 并开 draft PR,不等验证结束;验证结果到达即写进报告。` | ` -
每个可编译小步即 commit + push;有可展示内容即开 draft PR;接管只认远程分支最后 sha。` |

The dropped tail 「验证结果到达即写进报告」 survives at os-dev.md :95 (「未读到的判决写 NOT
MEASURED」) and :311 (「报告在本地验证走完时交付」).

## 3. SKILL.md deletion list — each retired line's surviving home

| retired line (base :493–:501) | surviving home |
|---|---|
| `死认领回收:认领 >~24h ⇒ 疑死;判死主腿 = 搜引用本卡的 PR、读其 merged/merged_at。` |
**retired outright** — the ruling replaces liveness judgement with the
human's word (:493 「⛔ 不判死活」) |
| `⛔ 判死不读 closes-list;承诺分支缺席与提交扫描失效只能支持判死、永不单独确立。` | retired outright
(no liveness judgement exists to bound) |
| `零引用 PR ⇒ 停下发问,⛔ 不判什么都没落地。` | retired outright; the "ask first" half
is the protocol itself — the handover IS the human's answer copied with
provenance (:494) |
| `回收前先救工作树:向任何派发 worktree 提交前先过存活/所有权检查。` | **retired outright** — the
hard fact at :492: a remote container's worktree is reclaimed with the
session; there is nothing to rescue |
| `或对树最新 mtime 过明确年龄阈值;⛔ 不凭 GitHub 侧静默动手。` | retired outright (same
reason); 「⛔ 不凭 GitHub 侧静默动手」 survives as the provenance requirement
(:494) |
| `过栏后,派发 worktree 的未提交改动先 WIP commit 到派发分支并 push,sha 记进回收评论。` | :492
(every compilable step is pushed by the dev — the WIP-rescue is moved to
the writer side, before the cut) + :496 ④ (the last pushed sha in the
handover record) |
| `WIP commit 标 INCOMPLETE AND UNREVIEWED;续派者 diff 它,⛔ 不无审续建。` | :496 ④
「一句状态」 — the taker records the branch's state and continues from the
remote sha; "diff before continuing" is the taker's ordinary care under
「读者只验①③形状」 |
| `WIP 信息只写观察到的(脏路径/行数/sha),⛔ 不写席位行为的现在时断言。` | :496 ④ (sha + one status
sentence) — no WIP commit is written by anyone but the dev itself |
| `再评论询问,静默一窗后释放回队(`Release:` 行载因);有带提交活分支的认领永不回收。` | :494 ① (the
`Release:` line, now with provenance instead of a silence window) + :497
(every live claim named) — 「有带提交活分支的认领永不回收」 is retired: a pushed branch
is precisely what the handover continues (:495 ③) |

## 4. PM mechanism assumptions — verified, one refuted

1. ✓ At `5e7d83c` = `32b5831` (no diff on the surface between them):
`CLAIM_RETRACTION_RULE` :1731 stated "⛔ never a DIFFERENT author's
line", `claimRetractions` skipped every candidate whose author differs
(:1778 `candidate.author === null || candidate.author !==
claim.author`), and `claimHandovers` judged cross-login claims after
`CROSS_AUTHOR_CLAIM_ROW_EFFECTIVE_AT` (:2073, `2026-09-19T03:45Z`).
2. ✓ Reproduced before the change (2026-09-21T03:5xZ,
`PM_SWEEP_REPO=objectstack-ai/objectstack`): `--pair 19373` → exit 4, `✗
C9 — card objectstack-ai#17518 (delivering open PR objectstack-ai#19373) — 2 authors hold LIVE claim
comments … `os-bill`'s 5646971772 at 2026-09-12T15:54:12Z is the claim
that stood; `os-litant`'s 5749581295 at 2026-09-20T11:43:41Z took the
card from `os-bill` (dated AFTER the effective instant 2026-09-19T03:45Z
— JUDGED)`; `--pair 19335` → exit 4, `✗ C9 — card objectstack-ai#18670 (delivering
open PR objectstack-ai#19335) — 3 authors … `os-litant`'s 5717305863 … stood;
`os-steve`'s 5736537462 … (listed, informational); `os-bill`'s
5749165780 at 2026-09-20T10:14:08Z took the card from `os-steve` (…
JUDGED)`. After the change both STILL exit 4 (same rows, the remedy now
printing the four-item comment) — as predicted, until the seats post the
handover comments below.
3. ✓ SKILL.md :149 reads exactly
「代执行他人指令的关闭、摘标、回收认领,评论带出处三件:谁的指令、原话、在哪说。」 (ASCII punctuation); the
reader now reads exactly those three fields and quotes the line unbroken
(`HANDOVER_PROVENANCE_SOURCE`).
4. Tier S — `node scripts/pm/check-governed-merges.mjs --pr N` is run
once the PR number exists; the result is in the report. The PR stays
draft.
5. **REFUTED — ruling item ② spelling.** `label-write --clear-assignees
--assign NEW` is refused by the tool: `--clear-assignees cannot be
combined with --assign/--unassign` (`scripts/pm/label-write.mjs`
:417–:419). The one-write assignee swap is `node
scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue N
--unassign OLD_LOGIN --assign NEW_LOGIN` (`computeAssigneeTarget`:
target = current − unassign + assign, one write, read back). SKILL.md
:495 and the handover comments below use that spelling.

## 5. The handover comments the seats post (verbatim — ⛔ not posted by
this PR, ⛔ nothing written on objectstack-ai#17518 / objectstack-ai#18670 / PR objectstack-ai#19373 / PR objectstack-ai#19335
here)

Both were **simulated offline against the live threads** (the REST rows
of each card plus the drafted comment appended): C9 state `null`
(clear), pool = the handover comment, governing branch = the continued
branch, declaration `declared` / `yes`, C8 = 0; controls — the same
comment without 在哪说 ⇒ C9 judged `true`; the same comment with the
session id blanked on the `Release:` line ⇒ C9 judged `true`.
Placeholders in CAPITALS are the poster's to fill (its own session id /
login, the UTC stamp). The 原话 / 在哪说 values copy the maintainer's words
that adopted this protocol for exactly these two PRs (5754717208 § the
maintainer's turns; 5754797404 「同意」, which names PR objectstack-ai#19373 and PR objectstack-ai#19335
as the two the ruling unblocks); a fresher instruction naming the card
directly is a better value, if the seat has one.

### objectstack-ai#17518 (PR objectstack-ai#19373) — posted by the `domain:spec#1` seat
(`os-litant`, the taker already holding claim 5749581295)

```text
Release: handover of claim 5646971772 (`session_01MkQhmuuJAVDjmeWNixwDDH`, `os-bill`, branch `claude/issue-17518-assembled-body-json-schema`) and of this seat's own claim 5749581295 (`session_01LvwGppdonww4zGLWZo5rho`) · 因: the earlier claimant is a dev subagent session that ended on 2026-09-12 and cannot post its own `Release:`; the taker has delivered the whole diff on PR objectstack-ai#19373 · 去向: the `Claim:` below — same seat, same branch
谁的指令: the maintainer (objectstack-ai#19240 — ruling 5754797404, recorded by the `domain:skills` seat 2 at 2026-09-21T02:57Z; the maintainer's words carried in 5754717208 by the `domain:spec` seat 2)
原话: 「某个 agent 开发了一半没有token了,就是需要新的 agent 重新认领,而且重新认领的时候 是不是不issue 的人员也要跟着改。」「把它从「补一种 Release: 拼写」升级成 「接管协议」」「同意」
在哪说: objectstack-ai#19240 comments 5754717208 (2026-09-21T02:44Z, the maintainer's verbatim turns in the `domain:spec` seat 2's session) and 5754797404 (2026-09-21T02:57Z, 「同意」 on shape A in the `domain:skills` seat 2's session)
Assignee: `os-project-manager` → `os-litant`, in the same label write as this comment: `node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 17518 --unassign os-project-manager --assign os-litant`
Claim: `domain:spec` seat 1 takes over objectstack-ai#17518 under SKILL.md's handover rule (认领 section), at DATE_TIME_UTC
Session: `session_01LvwGppdonww4zGLWZo5rho`
Branch: `claude/issue-17518-assembled-package-body-inert-json` (continued at remote sha `aac764cc36113b4e52820c1695715f000ccbe1b4`, the head of PR objectstack-ai#19373)
Clause-②: yes
Handover: the released claim's branch `claude/issue-17518-assembled-body-json-schema` — last pushed sha `ed8dea17bd510100320ab42dbac6ec2a78e99deb` (read from origin at 2026-09-21); status: superseded — the whole diff was re-delivered on PR objectstack-ai#19373 at `aac764c` (checks green, `## Contract review` pending), nothing from the old branch is carried.
```

Why the seat's own 5749581295 is named too: the reader would otherwise
carry TWO live `Claim:` comments by `os-litant` (C8). Named on the same
`Release:` line it is retracted by the same-login arm, and the fresh
`Claim:` in this comment is the only one standing. If the posting
session differs from `session_01LvwGppdonww4zGLWZo5rho`, the `Session:`
line carries the new one.

### objectstack-ai#18670 (PR objectstack-ai#19335) — posted by the live `domain:spec` seat
(POSTER_LOGIN / POSTER_SESSION_ID; the taker of record,
`session_01JbZnqu8bt6YqfJsr9vaFb3`, was retired at 2026-09-20T23:34Z)

```text
Release: handover of claims 5717305863 (`session_01LvwGppdonww4zGLWZo5rho`, `os-litant`, branch `claude/issue-18670-refinement-projection-census`), 5736537462 (`session_01AmH9bKvGoLjiY86Q4Z3og2`, `os-steve`, branch `claude/issue-18670-banned-keys-projection`) and 5749165780 (`session_01JbZnqu8bt6YqfJsr9vaFb3`, `os-bill`, branch `claude/issue-18670-propertynames-not-pattern-arm`) · 因: the first two claims' work is merged (PR objectstack-ai#18729, PR objectstack-ai#19137; both branches absent on origin), and the third claim's session was retired at 2026-09-20T23:34Z with its PR objectstack-ai#19335 reviewed and green — none of the three can post its own `Release:` · 去向: the `Claim:` below
谁的指令: the maintainer (objectstack-ai#19240 — ruling 5754797404, recorded by the `domain:skills` seat 2 at 2026-09-21T02:57Z; the maintainer's words carried in 5754717208 by the `domain:spec` seat 2)
原话: 「某个 agent 开发了一半没有token了,就是需要新的 agent 重新认领,而且重新认领的时候 是不是不issue 的人员也要跟着改。」「把它从「补一种 Release: 拼写」升级成 「接管协议」」「同意」
在哪说: objectstack-ai#19240 comments 5754717208 (2026-09-21T02:44Z, the maintainer's verbatim turns in the `domain:spec` seat 2's session) and 5754797404 (2026-09-21T02:57Z, 「同意」 on shape A in the `domain:skills` seat 2's session)
Assignee: `os-bill` → POSTER_LOGIN, in the same label write as this comment: `node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 18670 --unassign os-bill --assign POSTER_LOGIN` (a no-op when the poster IS `os-bill`; `pm:blocked` → `pm:dispatched` in that same write once the reader has landed)
Claim: `domain:spec` seat takes over objectstack-ai#18670 under SKILL.md's handover rule (认领 section), at DATE_TIME_UTC
Session: `POSTER_SESSION_ID`
Branch: `claude/issue-18670-propertynames-not-pattern-arm` (continued at remote sha `1dfe2f40bce77270758d9b31b01dd8d46875a290`, the head of PR objectstack-ai#19335)
Clause-②: yes
Handover: branch `claude/issue-18670-propertynames-not-pattern-arm` — last pushed sha `1dfe2f40bce77270758d9b31b01dd8d46875a290` (read from origin at 2026-09-21); status: `## Contract review` PASS recorded at 5749728565 on this head, both carriers stripped, checks green — nothing left to build, the landing is the only step. The two older branches are absent on origin (their work merged as PR objectstack-ai#18729 / PR objectstack-ai#19137).
```

Why all three claims are named: C9 walks every LIVE claim; naming only
5749165780 would leave `os-litant` → `os-steve` → NEW as two hand-overs,
the last dated after the instant — still red. The row prints exactly the
ids to name (:497 「线程上每条活认领都要点名」).

## 6. Four-axis analysis

### 「No liveness test」 (ruling; 5754717208 §4's 「点名的是活认领 ⇒ 拒」 not kept)

- **实际业务需求** — measured: the three specimens (objectstack-ai#17518 / PR objectstack-ai#19373; objectstack-ai#18670
/ PR objectstack-ai#19335; objectui#9370) are all cases where the human already knew
the claimant was gone and the machine could not: a subagent session that
ended 2026-09-12, a seat session retired at 23:34Z, a retired identity.
In every one the holder's silence was total, so a liveness heuristic
(>24h, later comments, PR search, mtime) would have said "dead" only by
luck of thresholds, and a holder that posts one late comment would have
flipped a correct takeover into a refusal. The maintainer's words:
「这种情况通常都是人类口头交代的」 — the decision is already taken by a human; the
reader's job is to verify the copy, not to re-decide.
- **项目长远合理性** — a reader that verifies provenance is a pure function of
the thread (contract-first, no workaround); a liveness heuristic is a
second, contradictable oracle beside the human's word and needs its own
thresholds, exceptions and reconciliation windows (which is what
:493–:501 had become: nine lines of them). Long-term cost of the chosen
option: a bad handover is possible on a bad instruction — but it is
auditable (谁的指令 / 原话 / 在哪说 are on the card) and repairable (:498 「误伤活席位
⇒ 令其追加式更正」).
- **防 AI 写错** — the accept set is closed and mechanical: three named
keys, an id + session on ONE line, fail closed on any gap, and the
refusal names the missing piece. Nothing to guess; an AI seat that
half-writes the comment is told which field. A liveness test would be
the opposite — a tolerance rule ("probably dead") that hides a wrong
takeover behind a green.
- **创业阶段不扩散需求** — the ruling's own reason: 「我们系统开发了太多无用的门禁,反而在浪费时间」.
Nine heuristic lines retired, five protocol lines added, no staged
transition (the heuristics are gone at once — 「短期不考虑渐进」).
- Recommendation held: no liveness test, per the ruling.

### 「C9 keeps one red」 (a bare cross-login `Claim:` with no `Release:`
at all)

- **实际业务需求** — the red exists for the measured claim-jumps (objectstack-ai#17852's two
seats eight hours apart, objectstack-ai#15811's silent assignee move); those are
exactly the shape left red. The two finished PRs it blocked were
handovers, not jumps — they had no channel to say so; now they have one
comment.
- **项目长远合理性** — one state, one row, one repair (the handover comment) —
no widening of C8, no second selector; the effective instant stays as
history and still gates the narrowed red only.
- **防 AI 写错** — deleting C9 would let any later `Claim:` silently govern
(the pre-objectstack-ai#18862 SUPERSEDED exit-0 reading); keeping the red but printing
the four-item comment as the remedy makes the correct act the shortest
path. A refused attempt is listed with its reason instead of a bare "2
authors hold live claims".
- **创业阶段不扩散需求** — no new gate, no new label, no new tool: the remedy is
a comment in the spelling the protocol already has; the only added code
path is the provenance read.
- Alternative weighed and refused: retiring C9 entirely (「太多无用的门禁」) —
refused because the ruling itself keeps 「真正的抢卡」 red, and a jump is a
real, measured, silent failure.

## 7. Tests and gates (head `7a66ffe`; every exit captured before any
pipe)

- `node scripts/pm/check-clause2-carriers.mjs --self-test` → exit 0,
**1091 cases pass** (1075 at `origin/main`, run from a temp copy in the
same tree; the roster floor unchanged; both new case groups sit inside
their existing batteries).
- `--pair 19373` / `--pair 19335` → exit 4 before AND after (rows quoted
in §4.2); after the change each row ends with the four-item remedy
(`grep -c 认领人不可达` = 1 per log).
- Offline simulation of the two handover comments (§5): C9 clear,
governing claim = the handover, declaration `declared/yes`; controls
red.
- Derived union (`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, 48 commands, derived from the tree at
`693afd7` after the `origin/main` merge and re-run at `7a66ffe`): all
**48 of 48** commands exit 0 (run 2026-09-21T03:56Z–04:15Z, sequential,
each exit captured before any pipe; the list reconciled with `--ran`);
the slowest, `pnpm check:pm-dispatch-gates`, ran its full 1883-case
battery green at this head.
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions`
first answered exit 3 (PREREQUISITE NOT MET: `@objectstack/formula` /
`@objectstack/lint` not built — NOT a finding); after `pnpm exec turbo
run build --filter=@objectstack/formula --filter=@objectstack/lint`
under `os-verify-lock.sh` (VERDICT command-exit 0, 203 s) it answers
exit 0.
- `pnpm check:pm-dispatch-gates` (845 s on this box) red once on an
EARLIER draft: its governed-read census found a `readFileSync` of
SKILL.md in this reader's self-test (my "same words" pin). Removed — see
Deviations — and re-run green at the final head.

## Deviations (declared)

1. **The "ONE sentence" property is not a governed read.** A self-test
pin that reads SKILL.md makes `check:pm-clause2-carriers` a derived
family of SKILL.md and needs a `GOVERNED_READ_FLOOR` row in
`scripts/pm/dispatch-gates.mjs` (outside this claim's surface; a
gate-derivation change). Kept instead: `CLAIM_HANDOVER_SENTENCE_LINES`
(the remedy prints the five lines verbatim) + the twin rule at review +
a shape pin (each line ≤ 120 bytes, no bullet, no issue id). Open
question for the seat: register the read so a SKILL.md edit that breaks
the sentence reds the reader (recommended; a two-line floor row).
2. **SKILL.md ceiling not lowered** (813 → could be 809):
`check-skill-line-ratchet.mjs` is outside the surface; headroom 4 is
reported, the seat lowers it if wanted.
3. **:474 left byte-identical** (see §2) — the alignment the dispatch
asked for is carried by the new bullets rather than by editing the line
that a sibling file quotes.
4. **Ruling ② spelling corrected** (`--unassign OLD --assign NEW`), see
§4.5.

## Acceptance notes (off-path; noted, not filed — ⛔ no card filed by
this dev)

- `scripts/pm/check-half-states.mjs` H47 leg (b) sentence still quotes
「释放回队(`Release:` 行载因)」 as "the dead-claim route" — that SKILL.md line is
retired here, so the quotation is stale prose in a remedy sentence (a
doc nit, not a defect; carrier: the `domain:skills` seat on its next
half-states touch).
- `references/platform-readings.md` :391 「处置 = 死认领回收加 worktree 抢救,⛔
不重核前提、不升级」 names the retired route (a host-signal disposition line;
outside this claim's surface — the seat's twin-rule follow-up, one line:
「处置 = 接管(认领节),⛔ 不重核前提、不升级」).
- `check-clause2-carriers.mjs`'s C9 docblock still carries the objectstack-ai#18862
ruling history verbatim (「the holder posts `Release:`; the taker posts
nothing until then」 as the ruling's quoted words) — kept as history, the
new paragraph below it states the change; no action.
- `.claude/skills/pm-dispatch/SKILL.md` :272 「维护者强制接管令 … ⛔
不取在飞卡,由原认领者跟完」 is the seat-level forced takeover (a blanket order) and
is not contradicted by a per-card handover on a named instruction; left
as is.

## 维护者速读(草稿)

**改了什么**:把「死认领回收」换成「接管协议」。一个 agent 做到一半没 token 了,新会话在**一条评论**里接管:① 跨账号
`Release:` 点名旧认领的评论 id 与 session ID,并带出处三件(谁的指令 / 原话 / 在哪说);② assignee
同笔换人;③ 新 `Claim:`(续用远程分支与 sha);④
一句交接状态。认领读者(`check-clause2-carriers.mjs`)按形状接受①③,不再判死活;C9 只剩「没有任何
`Release:` 的跨账号抢卡」一种红。SKILL.md 删掉九行判死启发式,换成五行接管规则;os-dev.md
把「早推分支」提为硬要求(每个可编译小步即 push)。

**为什么改**:两张已复核完毕的成品 PR(objectstack-ai#19373、objectstack-ai#19335)今天落不了地,只因为旧认领人已经不在、没人能替它写
`Release:`;而「代执行他人指令要带出处三件」这条规矩早就在 SKILL.md
里,只是读者不读。您的原话:「这种情况通常都是人类口头交代的……我们系统开发了太多无用的门禁」。


**风险与代价(含回滚)**:风险是一条编造出处的接管评论会被读者接受——但出处三件留在卡上可审,误伤活席位按既有规则追加更正。代价是读者多一条判形状的分支(+120
行,含自测)。回滚 = revert 本 PR,一次 revert 即回到判死启发式与旧 C9。

**席位意见**:(席位填写)

**你要做的**:本 PR 是受管面(`.claude/**`),由席位达档复核后落地,不需要您动手;落地后 spec 席按正文第 5
节的两条评论接管 objectstack-ai#17518 与 objectstack-ai#18670,两张 PR 即可入队。若您希望读者对「SKILL.md
与读者同句」做机械钉死(而非复核时人工核对),点一下头,席位在 `dispatch-gates.mjs` 登记一条 governed read
即可。

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…rd package body stages, and stop the record under-reporting functions (objectstack-ai#19373)

Fixes objectstack-ai#17518

Clause-②: yes

Executes ruling **A′** — decision batch objectstack-ai#192 item 3, comment 5748934194,
maintainer 「192 同意」. Its two steps, its refusals (A and B) and its
fences are followed as written; every place where the tree made me read
the ruling rather than transcribe it is called out below.

Base of every reading in this body: regeneration commit `96dd3549ff6`,
the head of the SIXTH merge.

> ⚠️ **The readings below were brought to this head by the seat, not by
the round that first wrote them.** Two merge rounds have run since the
first draft. Each figure corrected here is named in the correcting
round's own report on card objectstack-ai#17518 — comment 5750725852 for the first,
5750987577 for the second — and the seat re-verified the head, the
regenerated index and mergeability itself before editing. Anything not
listed in those two reports is the original round's reading, unchanged.

## The confidence gap the ruling asked me to close first

「whether `effect` is required or defaulted on the declaration schema —
read it, ⛔ do not mint a value」

**Defaulted.** `FlowFunctionDeclarationSchema.effect` is
`FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT)` where
that constant is `'pure'` (`automation/flow-function.zod.ts`). Measured,
not read off the source alone:
`FlowFunctionLoweredDeclarationSchema.safeParse({ handler: 'x' })`
succeeds and yields `{ handler: 'x', effect: 'pure' }`. The array member
of `functions` states `FlowFunctionEffectSchema.optional()` with **no**
default, so the two forms differ and neither is restated anywhere in
this diff — each JSON stage inherits its form's own optionality by
deriving from it.

That reading is what the producer writes: the bare-callable
normalisation uses `DEFAULT_FLOW_FUNCTION_EFFECT` and the array form
gets nothing.

## What landed

**`packages/spec/src/automation/flow-function.zod.ts`** —
`FlowFunctionLoweredDeclarationSchema` is exported (step 1), with its
`FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a
module-local `const`, and `automation/index.ts`'s `export *` only
re-exports what is already exported.

**`packages/spec/src/stack.zod.ts`** — two new bodies **beside**
`AssembledPackageBodySchema`:

- `ArtifactStagePackageBodySchema` — the on-disk artifact stage.
`functions` entries are the lowered spellings, `hooks[].handler` is a
string.
- `RecordStagePackageBodySchema` — the registry record stage: literally
`ArtifactStagePackageBodySchema.extend({ functions: … })` with
`functions[].handler` optional in both the map-record form and the array
form, and nothing else.

`AssembledPackageBodySchema`, `composeStacks` and the `cannot drift`
invariant are ⛔ untouched: those callables are live on the stage the
assembled body declares itself for, and narrowing it would refuse a
published composition function's own output. Both new schemas carry the
same structural `z.ZodType` annotation as the assembled body, for the
two reasons recorded there (TS7056; a named alias turning `stack.zod`
into a shared chunk).

**`packages/spec/src/api/package-api.zod.ts`** — the installed-package
row's `manifest` is rebound to the record stage (step 1). The
`z.unknown()` override and the docblock defending it are gone, and the
sentence that ruling A step 5 assigns to this edit is corrected in
place: those two members are **not** why `ArtifactPackageSchema` and
`ObjectStackDefinitionSchema` publish no JSON Schema —
`src/stack.zod.ts` is not one of the subpath namespaces
`build-schemas.ts` walks, so neither is ever reached by the emit loop.

**`packages/objectql/src/registry.ts`** — step 2.
`withDeclaredFunctionEntries` rewrites a bare callable `functions` map
entry to `{ handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT }` at the
assembly boundary, before `toRecordManifest` runs. `toRecordManifest`'s
structural rule is ⛔ untouched and no key is special-cased inside the
projection; the two spellings are simply made structurally equal ahead
of it. ⛔ No ref is minted, ⛔ no entry is dropped. The caller's manifest
is never mutated and a copy is made only when an entry really needed
rewriting.

## Two places where I read the ruling rather than transcribed it — both
stated so they can be overruled

1. **「`functions` entries the lowered declaration」 is implemented as
BOTH lowered members of `FlowFunctionEntrySchema`**, not only the record
one. `objectstack build` emits `{ myFn: 'myFn' }` for a bare entry and
`{ myFn: { handler: 'myFn', effect } }` for a declared one, so a stage
admitting only the record form would refuse artifacts this repo really
writes — the failure mode that withdrew letter B, one key across. Ruling
A′'s own step-4 control names both shapes (「a string and a lowered
record」). Measured: the artifact stage accepts a body carrying one of
each.
2. **The array member is transcribed, not derived.** `functions`' array
branch is declared inline inside the assembled body's own shape, and
narrowing it in place is the one thing this pair may not do. The
transcription's drift is guarded instead:
`stack-json-stage-package-body.test.ts` pins the authoring array entry's
key set equal to both JSON stages', so a key added there and not here
reddens by name.

## Acceptance, as ruling A′ lists it

| criterion | result |
|---|---|
| both bodies convert under `z.toJSONSchema` (self-test over the whole
body) | **YES** / **YES**; control: the assembled body still **NO**
(`Function types cannot be represented in JSON Schema`); probe controls
lit `z.string()` YES, dark `z.object({a: z.function()})` NO |
| the showcase-shaped manifest (`config.ts:244-249`) reports **2**
functions on the `GET /packages` row, the bare one as a handler-less
declaration | **2**:
`{"summarizeCompletedTask":{"effect":"pure"},"sweepProjectHealth":{"effect":"writes"}}`,
driven through the real `SchemaRegistry.installPackage` |
| `hooks` unchanged | unchanged: an inline handler is dropped (the key
is optional and admits that), a string handler survives verbatim. The
array `functions` form also keeps its entry:
`[{"name":"syncBilling","effect":"writes"}]` |
| `AssembledPackageBodySchema` / `composeStacks` / the invariant
untouched | untouched — no edit in those regions;
`assembled-package-body.test.ts` and
`compose-stacks-manifest-preserve.test.ts` stay green |
| the two `noted, not filed` corrections in the same edit | baseline
reason line: made TRUE by step 1 rather than reworded —
`automation/FlowFunctionLoweredDeclaration` is now in
`json-schema.manifest/automation.json`, so 「the lowered record …
publishes normally」 is now a fact. `package-api.zod.ts` docblock last
sentence: corrected in place, see above |

Stage separation, measured rather than asserted: the record stage
accepts the handler-less declaration and the **artifact** stage refuses
it; the assembled body accepts a live callable and **both** JSON stages
refuse it; both JSON stages still refuse an authoring glob and an
unknown key (`namesapce`). So the two keys moved from `unknown` to a
declaration, and nothing else moved.

## Reverse verification — two ablations, each restored with proof

Both ran against committed code, each with a `trap` restore, an on-disk
landing proof (anchor `grep -c` before/after plus a blob-hash change)
and a restore proof (`git hash-object` back to the HEAD blob, `git diff
HEAD` empty).

- **A1 — remove the producer normalisation**
(`toRecordManifest(withDeclaredFunctionEntries(manifest))` →
`toRecordManifest(manifest)`; anchor 1→0, injected 1, blob `b0af60d7…` →
`17b7c93c…`): `registry-package-manifest-serializable.test.ts` goes **1
failed / 15 passed**, naming the exact defect — `expected [
'sweepProjectHealth' ] to deeply equal [ 'summarizeCompletedTask', …(1)
]`. Restored blob `b0af60d7…`, diff empty.
- **A3 — collapse the record stage into the artifact stage**
(`jsonStageFunctionsKey(true)` → `(false)`; anchor 1→0, injected 2, blob
`60c13b43…` → `822bed8e…`): **2 failed / 79 passed** across two files —
`record accepts the handler-less declaration; ⛔ the ARTIFACT stage
refuses it` and `parses a row carrying the residual the projection
really produces`. So the one-key difference that IS the fourth stage is
load-bearing in both packages' pins. Restored blob `60c13b43…`, diff
empty.

No ablation is offered for 「both bodies convert」: that claim already
carries its discriminating control inside the same test file (the
assembled body must NOT convert), which is a lit/dark pair rather than
an assertion about itself.

## Tests and gates

All through `scripts/pm/os-verify-lock.sh` with
`OS_VERIFY_LOCK_SLOT=issue-17518`, verdicts read from the wrapper's own
`VERDICT command-exit` line and never a bare `$?`; every exit code
captured before any pipe. Wall-clock figures in the logs are SHARED-BOX
seconds.

- `pnpm --filter @objectstack/spec test` — **513 files / 14971 tests
passed, 1 todo** — the FULL suite, re-run on this head because the sixth
merge carried 128 commits of base movement including breaking spec
changes
- `pnpm --filter @objectstack/objectql test` — **303 files / 5057 tests
passed**
- `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2`
over the package-door / artifact population, enumerated by a name match
on `packages/runtime` for `package` or `artifact` so the population is
reproducible — **39 files / 512 tests passed**. ⚠️ The first attempt
exited 1 in 2 seconds and is recorded as NOT a red: the paths were
repo-root-relative while `pnpm exec` runs at the package root, and the
repo's own guard said so in words (`FILTER SELECTED NOTHING — 39 of the
39 path(s) you named will run no tests`). Re-run with package-relative
paths for the reading above.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — exit 0; both test layers compile (spec **53 files / 257
errors / 142 pins**; objectql **40 / 234 / 65**, unchanged). ⚠️ The spec
ledger moved from 54 / 259 / 144 by main's objectstack-ai#19364 arriving in a merge, ⛔
not by this PR.
- `pnpm --filter @objectstack/spec --filter @objectstack/objectql
typecheck` — both exit 0 on this head; the debt ledgers held shrink-only
(spec 53 files / 257 errors / 142 pinned signatures; objectql 40 / 234 /
65).
- `pnpm --filter @objectstack/spec build` exit 0 (34/34 declared `.d.ts`
present, `check-dts-references` resolved 378/378), and the whole
`@objectstack/runtime` dependency closure was rebuilt first, so nothing
below read a dist stale against 128 commits of main.

**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived from this tree, every command run
with its exit code written to a file, reconciled with `--ran`: **116
derived, 114 run, 2 NOT-MEASURED, 0 UNRUN**, and the tool's own verdict
line says so. **113 exit 0.** The two NOT-MEASURED are the tool's
DERIVED classification of an exit 3; a third measured nothing too, and
the tool cannot see it because its refusal code is 2. ⛔ None of the
three is a finding:

- `check:dual-build-cjs-loads` — exit **3**, its own `PREREQUISITE NOT
MET … ⛔ This is NOT a pass: nothing was measured` (66 packages have no
`dist`; it wants a whole-repo build).
- `check:type-check-debt` — exit **3**, same shape, same wording, wants
the full package closure built.
- `check-engine-split-ratio --days 90` — exit **2**, refuses on a
shallow clone whose oldest visible commit sits inside the 90-day window.
It says a ratio derived there would be 「real, plausible and WRONG」.

A fourth, `check:skill-examples`, first exited 1 on an unbuilt
`packages/client-react`; after building that package it re-runs
**green** — 258 prose examples type-check across 3 surfaces. Both
readings are stated here, and the reconciliation record carries ONE of
them — the green re-run — because the tool flags a doubly-recorded
family and says to make the record state one thing. The re-derivation on
the final head yields **116** families: `check:api-surface-declarations`
is gone (retired upstream by objectstack-ai#19024 mid-round) and
`check:gitlink-declared` is new, run green. No family is left unrun.

Ratchet families re-run after the last merge, on `96dd3549ff6`:
`check:generated` (all 15 artifacts up to date), `check:api-surface`,
`check:authorable-surface`, `check:export-origins`,
`check:declaration-map`, `check:docs`, `check:skill-refs`,
`check:entry-nameability`, `check:dual-source-exports`,
`check:spec-changes`, `check:spec-parsed-alias`,
`check:published-files`, `check:nul-bytes`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage` — all exit 0. Control characters: `grep
-naP` over every file I hand-edited returns nothing (exit 1).

## Generated artefacts in this diff, and why each moved

- `json-schema.manifest/automation.json`,
`authorable-surface/automation.json`,
`authorable-defaults/automation.json`, `api-surface/*`,
`export-origins/*`, `declaration-map/automation.json`,
`content/docs/references/**` — the new exports, regenerated by the
package's own `gen:` scripts. `authorable-defaults` records
`automation/FlowFunctionLoweredDeclaration:effect = "pure"`, which is
the confidence-gap reading in ledger form.
- `packages/spec/dropped-refinements.baseline.json` — four `api/*`
entries each gain one site (`…manifest.hooks.element.object`), counts
569 → 573. Cause: the record stage **declares** `hooks` where
`z.unknown()` declared nothing, so `HookSchema`'s `object` refinement
now reaches the runtime and not the published file. The ledger is
hand-edited by design and the build printed the exact delta.
- `skills/objectstack-platform/references/_index.md` — one generated
line listing `stack.zod.ts`'s exports.

## `skills/**` readings, and the landing tier

This diff touches `skills/objectstack-platform/references/_index.md`, so
the PR is **governed, Tier H** on its file list. ⛔ It stays a draft and
no AI seat merges, queues or arms auto-merge on it.

Both readings the skills rule requires, at merge base `c334ba0f3a6`:

- **changed file, whole file**: 41 lines before, 41 after — net **0**.
The diff is one regenerated line.
- **package total (sum of every `SKILL.md`)**: 6145 before, 6145 after —
net **0**.

`node scripts/check-skills-token-ratchet.mjs` exits 0 and classifies
this file as **generator-owned (measured, not ratcheted)**, so no
authored ceiling is charged.

## Clause ②, and the changeset is not one package's

`Clause-②: yes`, and two changesets because two published packages move:

- `@objectstack/spec` — **minor**. New exports, and the two
installed-package responses move from `z.unknown()` on `functions` /
`hooks` to declared JSON shapes. That is a narrowing on a published
declaration; what it does NOT withdraw is measured, on real producers:
the showcase shape, the array form and the already-lowered body an
artifact boot installs all parse.
- `@objectstack/objectql` — **patch**. `GET /packages` reports functions
it previously dropped. No API is added or removed; a read door stops
under-reporting. Grade it up if a payload gaining entries reads as minor
to the reviewer.

## Serial and merge state, re-taken by this seat

Changed-file map re-taken first-hand over all **33** open PRs (271 file
rows) rather than inherited. LIT control
`packages/spec/src/ui/action-params.zod.ts` resolves to objectstack-ai#19315; DARK
control `packages/spec/src/zzz-no-such.zod.ts` resolves to nothing.

- `packages/spec/src/automation/flow-function.zod.ts`,
`packages/spec/src/api/package-api.zod.ts`,
`packages/objectql/src/registry.ts` — **free**.
- `packages/spec/src/stack.zod.ts` — held by objectstack-ai#18482, objectstack-ai#19147, objectstack-ai#19314, all
below A′'s region. objectstack-ai#19147 landed during this round and merged cleanly
here (its `stack.zod.ts` hunk is a comment).
- `packages/spec/dropped-refinements.baseline.json` — also written by
objectstack-ai#19147 (landed, resolved here) and by the still-open **objectstack-ai#19335**, which
rewrites the same `measured` header and adds entries. That is a
line-level contention on a ledger whose correct value is recomputable:
whoever lands second re-runs `pnpm --filter @objectstack/spec build` and
re-applies the delta it prints. ⛔ Not a semantic collision.

`origin/main` has been merged **six** times on this branch. `objectstack-ai#19024`
(which retired `api-surface-declarations/`) came in early, which is why
no `api-surface-declarations/*.txt` appears in this diff. The fifth
merge brought **objectstack-ai#19363**, a BREAKING spec change. The **sixth** merge,
the head of this body, brought **128 commits** — so the full spec suite
was re-run rather than only the generated gates.

⛔ `scripts/pm/os-regen-merge.sh` was NOT used in either round — its
`rerun` arm is re-entrant and commits a revert of the operator's own
regeneration, filed as **objectstack-ai#19392**. Steps 1–3 of its documented order
were performed by hand, against a merge base captured BEFORE the merge
and an `origin/main` fetched into an OWNED ref so a sibling's fetch
could not move the target mid-round.

**The sixth merge decided THREE paths, and only one of them was a
conflict.** That gap is worth stating, because resolving only what a
conflict probe names would have landed a silent loss:

| path | routed | what the merge did | how it was resolved |
|:--|:--|:--|:--|
| `content/docs/references/index.mdx` | `merge=os-regen` | driver
deferred it, exit 0 — **main's side silently dropped** (merged blob
`6290447bd9a` == ours, != theirs `7e1f9b6f13e`) | main's side restored
into the WORKING TREE ONLY, then regenerated whole |
| `content/docs/references/api/package-api.mdx` | `merge=os-regen` |
same — **main's side silently dropped** (merged `988bedaa480` == ours,
!= theirs `d09cd420711`) | same |
| `packages/spec/dropped-refinements.baseline.json` | **not** routed |
exit 1 — the only real text conflict, one hunk, confined to three
summary counters in the `measured` header | both sides' entries unioned,
then the build adjudicated |

⚠️ **`package-api.mdx` appears in NO conflict list and never could.** It
text-merges cleanly driver-free, so a GitHub-condition probe cannot name
it; only the both-edited ROUTED set, computed per file against the
pre-merge base, finds it — which is exactly what `os-regen-merge.sh`
step 2 specifies and what the driver's own `$GIT_DIR/os-regen-pending`
record listed.

**The regenerated docs are the UNION, proven in both directions**
(added/removed line multisets compared as sets): `package-api.mdx`
identical at 20 and 14 lines; `index.mdx` identical at 12 and 6 lines,
excluding the two running-total lines — a union MUST move a total
neither side moves alone, so their disagreement is the signature of a
correct union rather than a failure, and the line counts already matched
(16/16, 10/10) before excluding them. The total is **re-derived, not
arithmetic**: base 1533, this branch alone 1534, main alone 1534, merged
tree **1535**, and 1535 is what `gen:schema` itself reports for the
merged sources. Main brought `DatasetSelection`, `DatasetCompareTo` and
`DatasetTotals` and retired `KernelSecurityScanResult` /
`KernelSecurityVulnerability`; this branch brought
`FlowFunctionLoweredDeclaration`. All survive, asserted through the
published export map of the freshly built dist with a dark control (an
invented export name reads undefined).

**The ledger was resolved by hand, and that is the only route
available.** `dropped-refinements.baseline.json` is hand-edited BY
DESIGN with no `gen:` script — its own description states why: *"a
generator would let a new gap be admitted by running a command instead
of by a decision, which is the silence this ledger exists to end."* The
build VALIDATES it bidirectionally and refuses; it never writes it. Both
sides' entries were unioned (union keys missing from the merged file:
**none**; merged keys not in the union: **none**; `api/DatasetSelection`
arrived from main via objectstack-ai#19638 and survives; main's removal of the
`fields.out.keyType` sites is kept — **nine** site lines at the merge
base, zero at this head and zero on main (lit control: 204 `"sites"`
keys at base; dark control 0). ⚠️ The merge round's own prose said
*five*; that was a narrative miscount caught by the merge-delta review
and re-counted by the seat. The FILE was always right), then
`gen:schema` adjudicated and measured 565 dropped sites across 205
published schemas — the union as resolved. One counter the build
corrected: `refinementSitesThatDidProject` read 357 and the build
measures 366.

⚠️ **That correction is filed as objectstack-ai#19681**, because nothing in the
repository would have caught it: two of the four `measured` counters
have no reader anywhere (lit control — the other two have two readers
each, dark control 0), so they can hold any number and every gate stays
green.

## Acceptance notes

- **noted, not filed**: regenerating
`packages/spec/api-surface-declarations/ui.txt` produced a 184-line
change that is a pure permutation of its own content — the same union
members in a different order, `0 removed, 0 added, 35 reshaped`.
Verified as a precedented shape rather than a defect: commit
`24d622b94b8`, a spec change touching **zero** files under
`packages/spec/src/ui/`, moved the same file by 5 lines whose sorted
content is byte-identical. The whole artefact was retired upstream by
objectstack-ai#19024 mid-round, so nothing of it survives in this diff and the
population is gone. **Carrier: none — the file no longer exists.**
- **noted, not filed**: `packages/objectql`'s tests resolve
`@objectstack/metadata-protocol` from `dist`, so after merging upstream
objectstack-ai#19277 the seven assertions in
`protocol-install-package-enable-on-install.test.ts` failed against a
stale build of a package this PR never touches; building that one
package turns all seven green. A local-environment reading, not a repo
defect, and `check:test-source-alias` already owns the aliased/unaliased
ledger this sits in. **Carrier: the next seat that runs objectql's suite
after a merge — it will see the same red and should build the dependency
before reading it as a finding.**

## 维护者速读(草稿)

**改了什么** —— 一个包的「包体」在平台里其实要经过四个阶段:作者写的、内存里装配好的、落盘成 artifact
的、注册表记录下来的。前两个早有声明,后两个从来没有。这次把后两个补上:`ArtifactStagePackageBodySchema`(落盘
artifact)和
`RecordStagePackageBodySchema`(注册表记录),放在既有的装配体**旁边**,装配体一个字不动。同时修好一个生产者缺陷:`GET
/packages` 以前会把「裸写的函数」整条漏报,现在两种写法都报。

**为什么改** —— 两件事各有代价。其一,装配体里有两个键(`functions`、`hooks`)声明了「可以是一个活的函数」,而
JSON Schema 表达不了函数,于是**任何嵌入它的接口都会整份丢掉自己的 JSON Schema**;读 API
只能把这两个键写成「什么都收、不检查」。其二,我们自己发布的 showcase 声明了 2 个函数,而 `GET /packages` 只报 1
个——机器可读的读门把事实说少了。

**风险与代价(含回滚)** ——
风险集中在一处:那两个键从「什么都收」变成「按声明收」,理论上可能拒掉今天能读的行。已实测三种真实生产者(showcase
的写法、数组写法、artifact 启动装回来的写法)全部照常通过,并且用两次消融证明了这些断言真的会红而不是摆设。⛔ 装配体与
`composeStacks` 未动,所以 `os dev` / `os serve` 的行为不受影响——这正是上一版裁决 B
被撤回的原因,这次没有重蹈。回滚:两个 spec 改动与 objectql 改动互相独立,`git revert`
任一半都不会让另一半变红;最小回滚是把 `package-api.zod.ts` 的那一行绑回装配体,新声明留着不用。

**席位意见** ——

**你要做的** —— 这个 PR 的文件里有一份 `skills/**` 的生成文件,按规则整单属于 Tier
H,**只有你(或你授权的批准)能让它落地**;AI 席位不会合并、不会排队、不会解除 draft。请看两点:①
`@objectstack/objectql` 我打的是 `patch`,理由是「读门修复、不增删 API」,若你认为「载荷多出条目」应算
minor,说一声即可改;② `functions` 的声明式阶段我按「两种 lowered 写法都收」实现(理由写在上面第 1
条),如果裁决本意是只收记录式那一种,也请直接说,那会让 `objectstack build` 今天写出的一种 artifact 被拒。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants